diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..69ff892 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + +jobs: + lint-and-test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev,test]" + + - name: Run pre-commit + run: pre-commit run --all-files + + - name: Run pytest + run: pytest diff --git a/.github/workflows/test_pypi.yml b/.github/workflows/test_pypi.yml new file mode 100644 index 0000000..f3e69e9 --- /dev/null +++ b/.github/workflows/test_pypi.yml @@ -0,0 +1,39 @@ +name: Test PyPI + +on: + push: + branches: + - documentation + pull_request: + branches: + - documentation + +jobs: + build-n-publish: + name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@main + - name: Set up Python 3.8 + uses: actions/setup-python@v3 + with: + python-version: "3.8" + - name: Install pypa/build + run: >- + python -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: >- + python -m + build + --sdist + --wheel + --outdir dist/ + . + - name: Publish distribution 📦 to PyPI + if: startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index b6e4761..8ab39ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -127,3 +128,13 @@ dmypy.json # Pyre type checker .pyre/ + + +# Other +Notebooks +xcatPhantom +pytheranostics/local +pytheranostics/.vscode + +pytheranostics/data/s-values +pytheranostics/data/ICRP_phantom_masses \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fdd62c6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,58 @@ +repos: + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + files: '^(pytheranostics/|tests/)|^[^/]+\.py$' + + - repo: https://github.com/pre-commit/mirrors-isort + rev: v5.10.1 + hooks: + - id: isort + args: ["--profile", "black"] + files: '^(pytheranostics/|tests/)|^[^/]+\.py$' + + - repo: https://github.com/PyCQA/flake8 + rev: 7.3.0 + hooks: + - id: flake8 + files: '^(pytheranostics/|tests/)|^[^/]+\.py$' + + - repo: https://github.com/PyCQA/pydocstyle + rev: 6.3.0 + hooks: + - id: pydocstyle + files: '^pytheranostics/.*\.py$' + args: ["--convention=numpy"] + additional_dependencies: ["tomli"] # For reading pyproject.toml config + + # Temporarily disabled - mypy follows imports to legacy code with type issues + # - repo: https://github.com/pre-commit/mirrors-mypy + # rev: v1.16.1 + # hooks: + # - id: mypy + # files: '^(tests/).*\.py$' + # additional_dependencies: [pandas-stubs>=2.0.0, types-requests] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + files: '^(pytheranostics/|tests/)|^[^/]+\.(py|md|yml|yaml|toml)$' + - id: trailing-whitespace + files: '^(pytheranostics/|tests/)|^[^/]+\.(py|md|yml|yaml|toml)$' + - id: check-added-large-files + args: ['--maxkb=1000'] + + - repo: local + hooks: + - id: pytest-smoke + name: pytest (smoke tests only) + entry: python + language: python + args: ["-m", "pytest", "-m", "smoke", "--tb=short"] + pass_filenames: false + stages: [pre-commit] + # NOTE: Keep these dependencies in sync with pyproject.toml [project.dependencies] + additional_dependencies: ["pytest>=7.0", "numpy", "matplotlib", "pandas", "pydicom", "openpyxl", "rt-utils", "scikit-image", "simpleitk", "lmfit"] diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..ca0cf3c --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,35 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.12" + # You can also specify other tool versions: + # nodejs: "19" + # rust: "1.64" + # golang: "1.19" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/source/conf.py + +# Optionally build your docs in additional formats such as PDF and ePub +# formats: +# - pdf +# - epub + +# Optional but recommended, declare the Python requirements required +# to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..17e15f2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Current File", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bcce03e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python.defaultInterpreterPath": "./.venv/Scripts/python.exe", + "python.terminal.activateEnvironment": true, + "git.useIntegratedTerminal": true +} diff --git a/CHANGES.md b/CHANGES.md index e69de29..0b6522b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2024-03-19 + +### Added +- Initial release of PyTheranostics +- Basic package structure and modules +- Core functionality for nuclear medicine image processing +- Dosimetry calculation tools +- DICOM handling capabilities +- Calibration utilities +- Quality control tools +- Registration and segmentation modules +- Visualization and plotting features + +### Changed +- Migrated from doodle to PyTheranostics +- Updated package name and structure +- Improved documentation + +### Fixed +- Initial bug fixes and improvements diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..59be7d1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,167 @@ +# Contributing to PyTheranostics + +We love your input! We want to make contributing to PyTheranostics as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features +- Becoming a maintainer + +## Development Setup + +### Prerequisites + +- Python 3.8 or higher +- Git + +### Setting Up Your Development Environment + +1. **Fork and clone the repository:** + ```bash + git clone https://github.com/qurit/PyTheranostics.git + cd PyTheranostics + ``` + +2. **Create a virtual environment:** + ```bash + # Windows + python -m venv .venv + .venv\Scripts\activate + + # Linux/Mac + python -m venv .venv + source .venv/bin/activate + ``` + +3. **Install development dependencies:** + ```bash + python setup_dev.py + ``` + + This script will: + - Verify you're in a virtual environment + - Install the package in editable mode + - Install all development dependencies (pytest, black, flake8, mypy, etc.) + - Set up pre-commit hooks for automated code quality checks + +### Pre-commit Hooks + +Pre-commit hooks automatically run quality checks when you commit code. They only check **files you've modified**, making them non-disruptive to existing code: + +- **Code formatting** (black) - Automatic Python code formatting +- **Import organization** (isort) - Sorts imports into standard → third-party → local +- **Linting** (flake8) - Style violations, unused imports, code issues +- **Type checking** (mypy) - Static type analysis +- **Basic file hygiene** - Trailing whitespace, end-of-file, large files +- **Smoke tests** (pytest) - Quick functionality checks + +**Manual run:** `pre-commit run --all-files` (checks entire codebase) + +### Test Categories + +We use pytest markers to categorize tests: + +```python +# Smoke test (critical test, fails fast if system is not correctly configured) +@pytest.mark.smoke +def test_basic_case(): + # Fast test + +# No marker = regular tests +def test_detailed_calculation(): + # Standard or slow test + +# Slow tests should only be run occasionally, during merges +@pytest.mark.slow +def test_big_simulation(): + # Slow test +``` + +### Development Workflow + +Once your environment is set up, you can use these commands: + +```bash +# Run all tests +pytest + +# Run only smoke tests (fast) +pytest -m smoke + +# Format code (line length, spacing, quotes) +black . + +# Sort and organize imports +isort . + +# Lint code (style violations, unused imports, etc.) +flake8 + +# Type check (configured to ignore import issues by default) +mypy pytheranostics/calibrations/gamma_camera.py + +# Run all pre-commit checks manually +pre-commit run --all-files + +# Run quality checks (combination) +pytest -m smoke && black --check . && isort --check-only . && flake8 && mypy pytheranostics +``` + +### Type Checking with Mypy + +Mypy is configured in `pyproject.toml` to be development-friendly (ignores missing imports by default). For focused development: + +```bash +# Check single file (uses project config) +mypy pytheranostics/calibrations/gamma_camera.py + +# Skip imports entirely (fastest, most focused) +mypy --follow-imports=skip pytheranostics/dosimetry/BaseDosimetry.py + +# Check entire package +mypy pytheranostics/ +``` + +**Note**: Mypy is disabled in pre-commit hooks during incremental type annotation adoption. + +## Development Process + +We use GitHub to host code, to track issues and feature requests, as well as accept pull requests. + +1. Fork the repo and create your branch from `main`. +2. If you've added code that should be tested, add tests. +3. If you've changed APIs, update the documentation. +4. Ensure the test suite passes. +5. Make sure your code lints. +6. Issue that pull request! + +## Pull Request Process + +1. Update the README.md with details of changes to the interface, if applicable. +2. Update the CHANGES.md with details of your changes. +3. The PR will be merged once you have the sign-off of at least one other developer. + +## Any contributions you make will be under the MIT Software License + +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using GitHub's [issue tracker](https://github.com/qurit/PyTheranostics/issues) + +We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/qurit/PyTheranostics/issues/new); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. diff --git a/MANIFEST.in b/MANIFEST.in index d8ebe61..6a543ff 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include doodle/kernels/*.hdr -include doodle/kernels/*.img -include doodle/kernels/*.dcm -include doodle/isotope_data/*.json \ No newline at end of file +include pytheranostics/kernels/*.hdr +include pytheranostics/kernels/*.img +include pytheranostics/kernels/*.dcm +include pytheranostics/isotope_data/*.json \ No newline at end of file diff --git a/README.md b/README.md index f57f8b8..a3c4b04 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,65 @@ +[![Documentation Status](https://readthedocs.org/projects/pytheranostics/badge/?version=latest)](https://docs.pytheranostics.qurit.ca/en/latest/?badge=latest) + # PyTheranostics -A software packate to accelerate research in personalized theranostics with preclinical and clinical dosimetry + +A comprehensive Python library for nuclear medicine image processing and dosimetry calculations. + +## Overview + +PyTheranostics is a powerful toolkit designed for processing nuclear medicine scans and performing dosimetry calculations. It provides a complete workflow from image processing to absorbed dose calculations in target organs. + +## Features + +- Image processing and analysis +- Dosimetry calculations +- DICOM handling and manipulation +- Calibration tools +- Quality control utilities +- Registration and segmentation tools +- Visualization and plotting capabilities + +## Installation + +```bash +pip install pytheranostics +``` + +## Quick Start + +```python +import pytheranostics as tx + +# Load and process images +image = tx.ImagingDS.load_dicom("path/to/dicom") + +# Perform dosimetry calculations +dose = tx.dosimetry.calculate_absorbed_dose(image) + +# Visualize results +tx.plots.plot_dose_distribution(dose) +``` + +## Documentation + +For detailed documentation, visit our [documentation page](https://pytheranostics.readthedocs.io/). + +## Contributing + +We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details. + +## License + +This project is licensed under the terms of the LICENSE file included in the repository. + +## Citation + +If you use PyTheranostics in your research, please cite: + +``` +@software{pytheranostics2024, + author = {Sara Kurkowska, Pedro Esquinas,Carlos Uribe}, + title = {PyTheranostics: A Python Library for Nuclear Medicine Processing and Dosimetry}, + year = {2024}, + url = {https://github.com/qurit/PyTheranostics} +} +``` diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/API/modules.rst b/docs/source/API/modules.rst new file mode 100644 index 0000000..19e2f09 --- /dev/null +++ b/docs/source/API/modules.rst @@ -0,0 +1,84 @@ +DICOM Tools +=========== + +.. automodule:: pytheranostics.dicomtools.dicomtools + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dicomtools.dicom_receiver + :members: + :undoc-members: + :show-inheritance: + + +Calibrations +============ + +.. automodule:: pytheranostics.calibrations.gamma_camera + :members: + :undoc-members: + :show-inheritance: + + +Dosimetry +========= + +.. automodule:: pytheranostics.dosimetry.base_dosimetry + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.bone_marrow + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.dosiomicsclass + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.dvk + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.image_analysis + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.mc + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.olinda + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.organ_s_dosimetry + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.dosimetry.voxel_s_dosimetry + :members: + :undoc-members: + :show-inheritance: + + +Fitting +======= + +.. automodule:: pytheranostics.fits.fits + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytheranostics.fits.functions + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/_static/dosimetry_workflow.png b/docs/source/_static/dosimetry_workflow.png new file mode 100644 index 0000000..231b023 Binary files /dev/null and b/docs/source/_static/dosimetry_workflow.png differ diff --git a/docs/source/_static/logo.png b/docs/source/_static/logo.png new file mode 100644 index 0000000..c0ad0b1 Binary files /dev/null and b/docs/source/_static/logo.png differ diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst new file mode 100644 index 0000000..102b9a9 --- /dev/null +++ b/docs/source/changelog.rst @@ -0,0 +1,2 @@ +Changelog +========== diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..e6201ff --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,107 @@ +"""Sphinx configuration file for PyTheranostics documentation.""" + +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) +sys.path.insert(0, os.path.abspath(".")) + + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "PyTheranostics" +copyright = "2024, Carlos Uribe" +author = "Carlos Uribe" + +# The full version, including alpha/beta/rc tags +release = "0.1.0" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx.ext.githubpages", + "sphinx.ext.mathjax", + "myst_parser", + "nbsphinx", + "sphinx_copybutton", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "**/.ipynb_checkpoints"] + +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", +} + +autodoc_mock_imports = ["radiomics", "gatetools", "itk"] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] + +# -- Extension configuration ------------------------------------------------- +napoleon_google_docstring = True +napoleon_numpy_docstring = True +napoleon_include_init_with_doc = True +napoleon_include_private_with_doc = True +napoleon_include_special_with_doc = True +napoleon_use_admonition_for_examples = True +napoleon_use_admonition_for_notes = True +napoleon_use_admonition_for_references = True +napoleon_use_ivar = True +napoleon_use_param = True +napoleon_use_rtype = True +napoleon_type_aliases = None + +nbsphinx_execute = "never" +nbsphinx_allow_errors = False +nbsphinx_codecell_lexer = "python" + +myst_enable_extensions = [ + "colon_fence", + "deflist", +] + +_CONTRIB_EXTENSION = "sphinxcontrib.contributors" +try: + __import__(_CONTRIB_EXTENSION) +except ImportError: # pragma: no cover - optional dependency + _contributors_available = False +else: + _contributors_available = True + extensions.append(_CONTRIB_EXTENSION) + + +def setup(app): + """Register a fallback contributors directive when the extension is missing.""" + if _contributors_available: + return + + from docutils import nodes + from docutils.parsers.rst import Directive + + class _ContributorsDirective(Directive): + has_content = False + required_arguments = 1 + + def run(self): + repo = self.arguments[0] + paragraph = nodes.paragraph() + paragraph += nodes.Text( + "Install 'sphinx-contributors' to render the contributors list. " + f"In the meantime see https://github.com/{repo}/graphs/contributors." + ) + return [paragraph] + + app.add_directive("contributors", _ContributorsDirective) diff --git a/docs/source/examples/data/016/test016.dcm b/docs/source/examples/data/016/test016.dcm new file mode 100644 index 0000000..0eb6d78 Binary files /dev/null and b/docs/source/examples/data/016/test016.dcm differ diff --git a/docs/source/examples/data/test.dcm b/docs/source/examples/data/test.dcm new file mode 100644 index 0000000..cd9948f Binary files /dev/null and b/docs/source/examples/data/test.dcm differ diff --git a/docs/source/examples/data/test0034_2.dcm b/docs/source/examples/data/test0034_2.dcm new file mode 100644 index 0000000..68a9b1a Binary files /dev/null and b/docs/source/examples/data/test0034_2.dcm differ diff --git a/docs/source/examples/data/test016.dcm b/docs/source/examples/data/test016.dcm new file mode 100644 index 0000000..6d5f808 Binary files /dev/null and b/docs/source/examples/data/test016.dcm differ diff --git a/docs/source/examples/data/testimages/0034.dcm b/docs/source/examples/data/testimages/0034.dcm new file mode 100644 index 0000000..0ac8dec Binary files /dev/null and b/docs/source/examples/data/testimages/0034.dcm differ diff --git a/docs/source/examples/data/testimages/016.dcm b/docs/source/examples/data/testimages/016.dcm new file mode 100644 index 0000000..aaf9877 Binary files /dev/null and b/docs/source/examples/data/testimages/016.dcm differ diff --git a/docs/source/examples/data/testimages/spect_counts.dcm b/docs/source/examples/data/testimages/spect_counts.dcm new file mode 100644 index 0000000..21ad36d Binary files /dev/null and b/docs/source/examples/data/testimages/spect_counts.dcm differ diff --git a/docs/source/examples/data/testimages/spect_counts_out.dcm b/docs/source/examples/data/testimages/spect_counts_out.dcm new file mode 100644 index 0000000..05896e5 Binary files /dev/null and b/docs/source/examples/data/testimages/spect_counts_out.dcm differ diff --git a/docs/source/extensions/sphinx_github_contributors.py b/docs/source/extensions/sphinx_github_contributors.py new file mode 100644 index 0000000..b1d6952 --- /dev/null +++ b/docs/source/extensions/sphinx_github_contributors.py @@ -0,0 +1,48 @@ +import requests +from sphinx.util import logging + +logger = logging.getLogger(__name__) + + +def fetch_github_contributors(app): + """Fetch contributors via the GitHub API and write a simple RST list.""" + username = app.config.github_username + repository = app.config.github_repository + output_file = app.config.contributors_output_file + + if not username or not repository: + logger.warning( + "GitHub username or repository not configured. Skipping contributors fetch." + ) + return + + url = f"https://api.github.com/repos/{username}/{repository}/contributors" + response = requests.get(url) + contributors = response.json() + + if "message" in contributors: + logger.error(f"Error fetching contributors: {contributors['message']}") + return + + contributors_list = [] + for contributor in contributors: + contributors_list.append( + f"- {contributor['login']} (contributions: {contributor['contributions']})" + ) + + contributors_text = "\n".join(contributors_list) + + with open(output_file, "w") as file: + file.write("Contributors\n") + file.write("============\n\n") + file.write(contributors_text) + + logger.info(f"Contributors list written to {output_file}") + + +def setup(app): + """Register config values and connect the fetch hook.""" + app.add_config_value("github_username", None, "env") + app.add_config_value("github_repository", None, "env") + app.add_config_value("contributors_output_file", "../contributors.rst", "env") + app.connect("builder-inited", fetch_github_contributors) diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..c7199d9 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,95 @@ +.. PyTheranostics documentation master file, created by + sphinx-quickstart on Thu May 23 12:47:39 2024. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +PyTheranostics Documentation +============================ + +PyTheranostics is a comprehensive Python library for nuclear medicine image processing and dosimetry calculations. It provides a complete workflow from image processing to absorbed dose calculations in target organs. + +.. toctree:: + :maxdepth: 2 + :caption: Getting Started + + intro/overview + intro/installation + usage/basic_usage + +.. toctree:: + :maxdepth: 1 + :caption: Tutorials + + tutorials/index + +.. toctree:: + :maxdepth: 2 + :caption: Reference + + API/modules + changelog + +Features +-------- + +* Image processing and analysis +* Dosimetry calculations +* DICOM handling and manipulation +* Calibration tools +* Quality control utilities +* Registration and segmentation tools +* Visualization and plotting capabilities + +Installation +------------ + +You can install PyTheranostics using pip: + +.. code-block:: bash + + pip install pytheranostics + +For development installation: + +.. code-block:: bash + + pip install -e ".[dev]" + +Quick Start +----------- + +.. code-block:: python + + import pytheranostics as pth + + # Load and process images + image = pth.ImagingDS.load_dicom("path/to/dicom") + + # Perform dosimetry calculations + dose = pth.dosimetry.calculate_absorbed_dose(image) + + # Visualize results + pth.plots.plot_dose_distribution(dose) + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + +License +------- + +This project is licensed under the terms of the MIT license. See the `LICENSE `_ file for details. + +Acknowledgements +---------------- + +We would like to thank everyone who has contributed to PyTheranostics. Visit the +`GitHub contributors graph `_ +for the up-to-date list of collaborators. + +.. footer:: + + Made with 💖 by the Pytheranostics team. diff --git a/docs/source/intro/installation.rst b/docs/source/intro/installation.rst new file mode 100644 index 0000000..a0a0c3a --- /dev/null +++ b/docs/source/intro/installation.rst @@ -0,0 +1,57 @@ +.. _intro-install: + +================== +Installation Guide +================== + +.. _faq-python-versions: + +Supported Python versions +========================= + +PyTheranostics requires Python 3.8 or higher. + +.. _intro-install-pytheranostics: + +Installing PyTheranostics +========================== + +You can install PyTheranostics and its dependencies from PyPI with:: + + pip install pytheranostics + +We strongly recommend that you install it in a dedicated virtual environment +to avoid conflicting with your system packages. + + +Things that are good to know +---------------------------- + +PyTheranostics is written in pure Python and depends on a few key Python packages (among others): + +* `numpy`_, a powerful Python library for numerical computing, providing support for large multi-dimensional arrays and matrices, along with a collection of mathematical functions to operate on these arrays. + +* `matplotlib`_, a comprehensive Python library for creating static, animated, and interactive visualizations, offering a wide range of plotting functions to generate publication-quality graphs, charts, and figures for data analysis and scientific exploration. + +* `pandas`_, a versatile Python library for data manipulation and analysis, offering intuitive data structures like DataFrames and Series, along with powerful tools for cleaning, transforming, and analyzing structured data from various sources, facilitating tasks such as data exploration, manipulation, and visualization. + +* `pydicom`_, a Python library for working with DICOM (Digital Imaging and Communications in Medicine) files, providing tools for reading, writing, and manipulating medical image data, facilitating tasks such as parsing metadata, extracting pixel data, and performing various operations on medical images in a standardized format. + +* `openpyxl`_, a Python library for working with Excel files, enabling users to read, write, and manipulate Excel spreadsheets programmatically, offering support for various Excel formats and features such as cell formatting, formulas, charts, and more, facilitating tasks such as data extraction, analysis, and reporting directly from Excel files. + +* `rt-utils`_, RT-Utils allows you to create or load RT Structs, extract 3d masks from RT Struct ROIs, easily add one or more regions of interest, and save the resulting RT Struct in just a few lines. + +* `scikit-image`_, a collection of algorithms for image processing and computer vision, providing a wide range of tools for image analysis, segmentation, feature extraction, and more, facilitating tasks such as image enhancement, restoration, and transformation for scientific and industrial applications. + +* `simpleitk`_, a simplified layer built on top of the Insight Segmentation and Registration Toolkit (ITK), providing a simplified interface for medical image processing tasks, such as image registration, segmentation, and filtering, facilitating tasks such as image analysis, visualization, and processing in medical imaging applications. + + + +.. _numpy: https://numpy.org/ +.. _matplotlib: https://matplotlib.org/ +.. _pandas: https://pandas.pydata.org/ +.. _pydicom: https://pydicom.github.io/ +.. _openpyxl: https://openpyxl.readthedocs.io/ +.. _rt-utils: https://github.com/qurit/rt-utils +.. _scikit-image: https://scikit-image.org/ +.. _simpleitk: https://simpleitk.readthedocs.io/en/master/ diff --git a/docs/source/intro/overview.rst b/docs/source/intro/overview.rst new file mode 100644 index 0000000..af48dda --- /dev/null +++ b/docs/source/intro/overview.rst @@ -0,0 +1,12 @@ +.. _intro-overview: + +========================== +Pytheranostics at a glance +========================== + +PyTheranostics is a library of tools to process nuclear medicine scans and take them through the dosimetry workflow to calculate the absorbed dose in target organs. The library is designed to be modular and extensible, so that new features can be added easily. + +.. figure:: ../_static/dosimetry_workflow.png + :alt: dosimetry_workflow + + The dosimetry workflow involves quantitative imaging, followed by segmentation of volumes of interest, and finally the calculation of absorbed dose in target organs. diff --git a/docs/source/tutorials/Data_Ingestion_Examples/Data_Ingestion_Examples.ipynb b/docs/source/tutorials/Data_Ingestion_Examples/Data_Ingestion_Examples.ipynb new file mode 100644 index 0000000..6e691f8 --- /dev/null +++ b/docs/source/tutorials/Data_Ingestion_Examples/Data_Ingestion_Examples.ipynb @@ -0,0 +1,210 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5a549687", + "metadata": {}, + "source": [ + "# PyTheranostics Data Ingestion\n", + "\n", + "This example notebook shows two ways to bring DICOM data into PyTheranostics:\n", + "\n", + "1. Auto-detection from a local directory (no network needed).\n", + "2. A built-in DICOM receiver that listens on a port and auto-organizes incoming data.\n", + "\n", + "Pick the approach that matches your setup. You can run both independently." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a86f0217", + "metadata": {}, + "outputs": [], + "source": [ + "# Imports\n", + "from pathlib import Path\n", + "import pytheranostics as tx\n", + "from pytheranostics.ImagingDS.dicom_ingest import auto_setup_dosimetry_study, extract_patient_metadata\n", + "\n", + "print('PyTheranostics version:', getattr(tx, '__version__', 'unknown'))" + ] + }, + { + "cell_type": "markdown", + "id": "b8ebd003", + "metadata": {}, + "source": [ + "## Approach 1: Auto-detect from an existing directory\n", + "\n", + "Point to a local folder containing your raw DICOM series (CT, SPECT/NM, and RTSTRUCT).\n", + "The helper will organize time points and extract injection/patient metadata automatically." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c4f34b6e", + "metadata": {}, + "outputs": [], + "source": [ + "# Set the base directory containing raw DICOM files (edit this)\n", + "base_dir = Path('/path/to/raw_dicom_folder') # <-- change me\n", + "\n", + "# Organize and extract metadata\n", + "study_info, ct_paths, spect_paths, rtstruct_files = auto_setup_dosimetry_study(\n", + " base_dir=base_dir,\n", + " patient_id=None, # auto-detect from DICOM headers\n", + " cleanup=True # flatten single-child nested folders if any\n", + ")\n", + "\n", + "# Quick summary\n", + "print('Patient ID:', study_info.get('patient_id'))\n", + "inj = study_info.get('injection_info', {})\n", + "print('Injection date:', inj.get('injection_date'), 'time:', inj.get('injection_time'))\n", + "print(f'CT time points: {len(ct_paths)}')\n", + "print(f'SPECT time points: {len(spect_paths)}')\n", + "print(f'RTSTRUCT files: {len(rtstruct_files)}')\n", + "\n", + "# Example: show first CT/SPECT time point folders (if present)\n", + "print('First CT tp:', ct_paths[0] if ct_paths else None)\n", + "print('First SPECT tp:', spect_paths[0] if spect_paths else None)\n", + "print('First RTSTRUCT:', rtstruct_files[0] if rtstruct_files else None)" + ] + }, + { + "cell_type": "markdown", + "id": "3d71dd38", + "metadata": {}, + "source": [ + "## Approach 2: Receive over the network and auto-organize\n", + "\n", + "Start a DICOM receiver that accepts C-STORE, writes files to disk, and then auto-organizes\n", + "them into a PatientID/CycleX/tpY folder structure based on StudyDate and cycle gaps." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8e3f6c42", + "metadata": {}, + "outputs": [], + "source": [ + "# Configure and start the receiver (edit storage_root as needed)\n", + "from pytheranostics.dicomtools.dicom_receiver import create_receiver\n", + "\n", + "storage_root = '/path/to/dicom_inbox' # <-- change me; incoming DICOM will land here first\n", + "receiver = create_receiver(\n", + " ae_title='PYTHERANOSTICS',\n", + " port=11112,\n", + " storage_root=storage_root,\n", + " auto_organize=True,\n", + " auto_organize_output_base=storage_root, # organized output; defaults to storage_root\n", + " auto_organize_cycle_gap_days=15, # new cycle if >=15 days between study dates\n", + " auto_organize_timepoint_separation_days=1,# separate timepoints by date\n", + " auto_organize_debounce_seconds=120 # wait 2 minutes after last file per patient\n", + ")\n", + "\n", + "receiver.start(blocking=False)\n", + "print('DICOM Receiver running on port 11112 (AE_TITLE=PYTHERANOSTICS)')\n", + "print('Tip: Point your PACS/modality to this AE Title and port on this machine.')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27125069", + "metadata": {}, + "outputs": [], + "source": [ + "# Optional: Health check (C-ECHO)\n", + "from pynetdicom import AE\n", + "from pynetdicom.sop_class import Verification\n", + "\n", + "ae = AE(ae_title='TX_DOCS_TEST')\n", + "ae.add_requested_context(Verification)\n", + "assoc = ae.associate('127.0.0.1', 11112, ae_title=b'PYTHERANOSTICS')\n", + "print('Association established:', getattr(assoc, 'is_established', False))\n", + "if getattr(assoc, 'is_established', False):\n", + " status = assoc.send_c_echo()\n", + " print('C-ECHO status:', hex(status.Status) if status else None)\n", + " assoc.release()" + ] + }, + { + "cell_type": "markdown", + "id": "dc2bc7e1", + "metadata": {}, + "source": [ + "### Where to find the organized data\n", + "\n", + "After reception, files are moved under: `storage_root/PatientID/CycleX/tpY/` with RTSTRUCT under `CT/RTstruct`.\n", + "Timepoints are grouped by StudyDate; cycles split when gaps between dates are ≥ the configured threshold (default 15 days)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "faa673ec", + "metadata": {}, + "outputs": [], + "source": [ + "# List organized cycles/timepoints for a patient (replace with your patient ID)\n", + "from pathlib import Path\n", + "patient_id = 'YOUR_PATIENT_ID' # <-- change me\n", + "patient_root = Path(storage_root) / patient_id\n", + "print('Patient root:', patient_root)\n", + "\n", + "if patient_root.exists():\n", + " for cycle_dir in sorted(patient_root.glob('Cycle*')):\n", + " print(' ', cycle_dir.name)\n", + " for tp_dir in sorted(cycle_dir.glob('tp*')):\n", + " print(' ', tp_dir.name, 'contents:', [p.name for p in tp_dir.iterdir() if p.is_dir()])\n", + "else:\n", + " print('No organized data found yet. Send data to the receiver and try again.')" + ] + }, + { + "cell_type": "markdown", + "id": "5841b563", + "metadata": {}, + "source": [ + "## Clean up (optional)\n", + "\n", + "Stop the background receiver when you're done." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a83b203c", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " receiver.stop()\n", + " print('Receiver stopped')\n", + "except NameError:\n", + " print('Receiver variable not defined in this session')" + ] + }, + { + "cell_type": "markdown", + "id": "54bd45e7", + "metadata": {}, + "source": [ + "## Next steps\n", + "\n", + "Use the organized `ct_paths`, `spect_paths`, and `rtstruct_files` from Approach 1 or the folders created by Approach 2 to build longitudinal studies and proceed with dosimetry.\n", + "Refer to the project documentation for examples creating `LongStudy` objects and running organ/voxel dosimetry." + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/source/tutorials/ROI_Mapping_Tutorial/ROI_Mapping_Tutorial.ipynb b/docs/source/tutorials/ROI_Mapping_Tutorial/ROI_Mapping_Tutorial.ipynb new file mode 100644 index 0000000..6973dd7 --- /dev/null +++ b/docs/source/tutorials/ROI_Mapping_Tutorial/ROI_Mapping_Tutorial.ipynb @@ -0,0 +1,403 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8ea3f3e2", + "metadata": {}, + "source": [ + "# ROI Mapping Tutorial: Per-Modality Mapping with JSON Configs\n", + "\n", + "This notebook demonstrates how to use PyTheranostics' per-modality ROI mapping features.\n", + "\n", + "## Why Per-Modality Mapping?\n", + "\n", + "In theranostics workflows, you often have:\n", + "- **CT masks** for morphology/volume (labeled with `_m` suffix)\n", + "- **SPECT masks** for activity quantification (labeled with `_a` suffix)\n", + "\n", + "Both modalities may have ROIs that map to the same canonical target (e.g., `Kidney_Left`), but using different source names:\n", + "- CT: `Kidney_L_m` → `Kidney_Left`\n", + "- SPECT: `Kidney_L_a` → `Kidney_Left`\n", + "\n", + "If you apply the same mapping to both studies, you can create conflicts where multiple sources map to the same target within a single study.\n", + "\n", + "**Solution:** PyTheranostics provides modality-aware mapping tools that:\n", + "1. Filter mappings to only keys present in each study\n", + "2. Detect conflicts automatically\n", + "3. Support JSON config files for reusability" + ] + }, + { + "cell_type": "markdown", + "id": "0e2e0e2c", + "metadata": {}, + "source": [ + "## Setup: Import Libraries" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "174c76fe", + "metadata": {}, + "outputs": [], + "source": [ + "import pytheranostics as tx\n", + "from pytheranostics.imaging_ds.longitudinal_study import LongitudinalStudy\n", + "\n", + "# For this tutorial, we'll assume you have CT and SPECT longitudinal studies loaded\n", + "# Example (replace with your actual data paths):\n", + "# longCT, longSPECT, inj, used_mappings = tx.imaging_ds.create_studies_with_masks(\n", + "# storage_root=\"/path/to/data\",\n", + "# patient_id=\"PATIENT_ID\",\n", + "# cycle_no=1,\n", + "# parallel=True,\n", + "# )" + ] + }, + { + "cell_type": "markdown", + "id": "b010209e", + "metadata": {}, + "source": [ + "## Approach 1: Inline Dictionaries (Quick Start)\n", + "\n", + "Define mappings directly in your notebook code." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b9fea896", + "metadata": {}, + "outputs": [], + "source": [ + "# Define modality-specific mappings\n", + "ct_mask_mapping = {\n", + " \"Liver\": \"Liver\",\n", + " \"Skeleton\": \"Skeleton\",\n", + " \"Kidney_L_m\": \"Kidney_Left\",\n", + " \"Kidney_R_m\": \"Kidney_Right\",\n", + " \"Spleen\": \"Spleen\",\n", + " \"Bladder\": \"Bladder\",\n", + " \"Parotid_L_m\": \"ParotidGland_Left\",\n", + " \"Parotid_R_m\": \"ParotidGland_Right\",\n", + " \"Submandibular_L_m\": \"SubmandibularGland_Left\",\n", + " \"Submandibular_R_m\": \"SubmandibularGland_Right\",\n", + " \"WBCT\": \"WholeBody\",\n", + "}\n", + "\n", + "spect_mask_mapping = {\n", + " \"Liver\": \"Liver\",\n", + " \"Skeleton\": \"Skeleton\",\n", + " \"Kidney_L_a\": \"Kidney_Left\",\n", + " \"Kidney_R_a\": \"Kidney_Right\",\n", + " \"Spleen\": \"Spleen\",\n", + " \"Bladder\": \"Bladder\",\n", + " \"Parotid_L_a\": \"ParotidGland_Left\",\n", + " \"Parotid_R_a\": \"ParotidGland_Right\",\n", + " \"Submandibular_L_a\": \"SubmandibularGland_Left\",\n", + " \"Submandibular_R_a\": \"SubmandibularGland_Right\",\n", + " \"WBCT\": \"WholeBody\",\n", + "}\n", + "\n", + "# Apply mappings (replace longCT and longSPECT with your actual study variables)\n", + "# result = LongitudinalStudy.apply_per_modality_mappings(\n", + "# ct_study=longCT,\n", + "# spect_study=longSPECT,\n", + "# ct_mask_mapping=ct_mask_mapping,\n", + "# spect_mask_mapping=spect_mask_mapping,\n", + "# )\n", + "\n", + "# # Check results\n", + "# print(f\"✓ CT mappings applied: {len(result['ct_applied'])}\")\n", + "# print(f\"✓ SPECT mappings applied: {len(result['spect_applied'])}\")\n", + "# if result['ct_conflicts'] or result['spect_conflicts']:\n", + "# print(\"⚠️ Conflicts detected!\")\n", + "# print(f\" CT conflicts: {result['ct_conflicts']}\")\n", + "# print(f\" SPECT conflicts: {result['spect_conflicts']}\")" + ] + }, + { + "cell_type": "markdown", + "id": "ad3eff3d", + "metadata": {}, + "source": [ + "## Approach 2: JSON Config File (Recommended for Projects)\n", + "\n", + "### Step 1: Create a JSON Config File\n", + "\n", + "Create a file named `roi_mappings.json` in your project directory with this structure:\n", + "\n", + "```json\n", + "{\n", + " \"_comment\": \"ROI mapping configuration for your project\",\n", + " \n", + " \"ct_mappings\": {\n", + " \"Liver\": \"Liver\",\n", + " \"Skeleton\": \"Skeleton\",\n", + " \"Kidney_L_m\": \"Kidney_Left\",\n", + " \"Kidney_R_m\": \"Kidney_Right\",\n", + " \"Spleen\": \"Spleen\",\n", + " \"Bladder\": \"Bladder\",\n", + " \"Parotid_L_m\": \"ParotidGland_Left\",\n", + " \"Parotid_R_m\": \"ParotidGland_Right\",\n", + " \"Submandibular_L_m\": \"SubmandibularGland_Left\",\n", + " \"Submandibular_R_m\": \"SubmandibularGland_Right\",\n", + " \"WBCT\": \"WholeBody\"\n", + " },\n", + " \n", + " \"spect_mappings\": {\n", + " \"Liver\": \"Liver\",\n", + " \"Skeleton\": \"Skeleton\",\n", + " \"Kidney_L_a\": \"Kidney_Left\",\n", + " \"Kidney_R_a\": \"Kidney_Right\",\n", + " \"Spleen\": \"Spleen\",\n", + " \"Bladder\": \"Bladder\",\n", + " \"Parotid_L_a\": \"ParotidGland_Left\",\n", + " \"Parotid_R_a\": \"ParotidGland_Right\",\n", + " \"Submandibular_L_a\": \"SubmandibularGland_Left\",\n", + " \"Submandibular_R_a\": \"SubmandibularGland_Right\",\n", + " \"WBCT\": \"WholeBody\"\n", + " }\n", + "}\n", + "```\n", + "\n", + "**Tip:** A template is available at `pytheranostics/data/roi_mappings_template.json` — copy and customize it!" + ] + }, + { + "cell_type": "markdown", + "id": "0e37b0a1", + "metadata": {}, + "source": [ + "### Step 2a: Load JSON After Studies Created" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "be61e104", + "metadata": {}, + "outputs": [], + "source": [ + "# Load mappings from JSON config file\n", + "json_config_path = \"roi_mappings.json\" # <-- Path to your JSON file\n", + "\n", + "# Uncomment to use:\n", + "# mappings = LongitudinalStudy.load_mappings_from_json(json_config_path)\n", + "# \n", + "# result = LongitudinalStudy.apply_per_modality_mappings(\n", + "# ct_study=longCT,\n", + "# spect_study=longSPECT,\n", + "# ct_mask_mapping=mappings['ct_mappings'],\n", + "# spect_mask_mapping=mappings['spect_mappings'],\n", + "# )\n", + "# \n", + "# print(f\"✓ Applied {len(result['ct_applied'])} CT mappings\")\n", + "# print(f\"✓ Applied {len(result['spect_applied'])} SPECT mappings\")\n", + "# \n", + "# # Show what was mapped\n", + "# print(\"\\nCT mappings:\")\n", + "# for src, dst in sorted(result['ct_applied'].items()):\n", + "# if src != dst:\n", + "# print(f\" {src} → {dst}\")\n", + "# \n", + "# print(\"\\nSPECT mappings:\")\n", + "# for src, dst in sorted(result['spect_applied'].items()):\n", + "# if src != dst:\n", + "# print(f\" {src} → {dst}\")" + ] + }, + { + "cell_type": "markdown", + "id": "0aa57f44", + "metadata": {}, + "source": [ + "### Step 2b: Pass JSON Config During Data Loading (One-Step Approach)\n", + "\n", + "You can apply mappings automatically when loading data by passing `mapping_config` to `create_studies_with_masks()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e43d7717", + "metadata": {}, + "outputs": [], + "source": [ + "# Load data AND apply mappings in one step\n", + "# Uncomment and customize:\n", + "\n", + "# longCT, longSPECT, inj, used_mappings = tx.imaging_ds.create_studies_with_masks(\n", + "# storage_root=\"/path/to/data\",\n", + "# patient_id=\"PATIENT_ID\",\n", + "# cycle_no=1,\n", + "# parallel=True,\n", + "# mapping_config=\"roi_mappings.json\", # <-- Mappings applied automatically!\n", + "# )\n", + "# \n", + "# # Unpack injection metadata\n", + "# InjectionDate = inj.get(\"InjectionDate\") or \"\"\n", + "# InjectionTime = inj.get(\"InjectionTime\") or \"\"\n", + "# InjectedActivity = str(inj.get(\"InjectedActivity\")) if inj.get(\"InjectedActivity\") is not None else \"\"\n", + "# PatientWeight_g = inj.get(\"PatientWeight_g\") if inj.get(\"PatientWeight_g\") is not None else None\n", + "# \n", + "# print(f\"✓ CT timepoints: {len(longCT.images)}\")\n", + "# print(f\"✓ SPECT timepoints: {len(longSPECT.images)}\")\n", + "# print(f\"✓ Mappings applied at load time from JSON config\")" + ] + }, + { + "cell_type": "markdown", + "id": "c01f2f87", + "metadata": {}, + "source": [ + "## Understanding the Result Dictionary\n", + "\n", + "The `apply_per_modality_mappings()` function returns a detailed diagnostic dictionary:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "96f80629", + "metadata": {}, + "outputs": [], + "source": [ + "# Example result structure (uncomment to see with your data):\n", + "# result = {\n", + "# 'ct_applied': { # Mappings actually applied to CT\n", + "# 'Kidney_L_m': 'Kidney_Left',\n", + "# 'Liver': 'Liver',\n", + "# ...\n", + "# },\n", + "# 'spect_applied': { # Mappings actually applied to SPECT\n", + "# 'Kidney_L_a': 'Kidney_Left',\n", + "# 'Liver': 'Liver',\n", + "# ...\n", + "# },\n", + "# 'ct_absent': [], # CT mapping keys not found in CT masks\n", + "# 'spect_absent': [], # SPECT mapping keys not found in SPECT masks\n", + "# 'ct_conflicts': {}, # CT conflicts: {target: [source1, source2]}\n", + "# 'spect_conflicts': {}, # SPECT conflicts: {target: [source1, source2]}\n", + "# }\n", + "\n", + "# Check for issues:\n", + "# if result['ct_absent']:\n", + "# print(f\"⚠️ CT mappings provided but not found: {result['ct_absent']}\")\n", + "# \n", + "# if result['spect_absent']:\n", + "# print(f\"⚠️ SPECT mappings provided but not found: {result['spect_absent']}\")\n", + "# \n", + "# if result['ct_conflicts']:\n", + "# print(f\"❌ CT conflicts detected: {result['ct_conflicts']}\")\n", + "# print(\" Fix: remove duplicate mappings to the same target\")\n", + "# \n", + "# if result['spect_conflicts']:\n", + "# print(f\"❌ SPECT conflicts detected: {result['spect_conflicts']}\")\n", + "# print(\" Fix: remove duplicate mappings to the same target\")" + ] + }, + { + "cell_type": "markdown", + "id": "f5b6a5a9", + "metadata": {}, + "source": [ + "## Adding Lesion Mappings\n", + "\n", + "You can add lesion or other one-off mappings using `manual_overrides`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c7c2e21a", + "metadata": {}, + "outputs": [], + "source": [ + "# Load base mappings from JSON\n", + "# mappings = LongitudinalStudy.load_mappings_from_json(\"roi_mappings.json\")\n", + "\n", + "# Add lesion mappings (apply to both CT and SPECT)\n", + "# lesion_overrides = {\n", + "# \"Lesion1_CT\": \"Lesion_1\",\n", + "# \"Lesion1_SPECT\": \"Lesion_1\",\n", + "# \"Lesion2\": \"Lesion_2\",\n", + "# }\n", + "\n", + "# result = LongitudinalStudy.apply_per_modality_mappings(\n", + "# ct_study=longCT,\n", + "# spect_study=longSPECT,\n", + "# ct_mask_mapping=mappings['ct_mappings'],\n", + "# spect_mask_mapping=mappings['spect_mappings'],\n", + "# manual_overrides=lesion_overrides, # <-- Added here\n", + "# )" + ] + }, + { + "cell_type": "markdown", + "id": "830358c4", + "metadata": {}, + "source": [ + "## Best Practices\n", + "\n", + "1. **Use JSON configs for projects** — easier to version control and share with collaborators\n", + "2. **Start from the template** — copy `pytheranostics/data/roi_mappings_template.json` and customize\n", + "3. **Check diagnostics** — always inspect `result['ct_conflicts']` and `result['spect_conflicts']`\n", + "4. **Review absent keys** — `result['ct_absent']` and `result['spect_absent']` may indicate typos\n", + "5. **Use consistent suffixes:**\n", + " - `_m` for CT/morphology ROIs (mass/volume)\n", + " - `_a` for SPECT/activity ROIs\n", + "6. **Version control your JSON** — commit `roi_mappings.json` to your repository\n", + "\n", + "## Valid Canonical Target Names\n", + "\n", + "PyTheranostics recognizes these canonical organ names:\n", + "- `Kidney_Left`, `Kidney_Right`\n", + "- `Liver`\n", + "- `Spleen`\n", + "- `Bladder`\n", + "- `ParotidGland_Left`, `ParotidGland_Right`\n", + "- `SubmandibularGland_Left`, `SubmandibularGland_Right`\n", + "- `BoneMarrow`\n", + "- `Skeleton`\n", + "- `WholeBody`\n", + "- `RemainderOfBody`\n", + "- `TotalTumorBurden`\n", + "- Lesions: `Lesion_1`, `Lesion_2`, etc. (format: `Lesion_N` where N is a positive integer)\n", + "\n", + "## Summary: Three Ways to Apply Mappings\n", + "\n", + "| Approach | When to Use | Code Example |\n", + "|----------|-------------|-------------|\n", + "| **Inline dicts** | Quick prototyping, one-off analysis | `result = LongitudinalStudy.apply_per_modality_mappings(ct_study=longCT, spect_study=longSPECT, ct_mask_mapping={...}, spect_mask_mapping={...})` |\n", + "| **JSON after load** | Reusable configs, already loaded data | `mappings = LongitudinalStudy.load_mappings_from_json(\"roi_mappings.json\"); result = LongitudinalStudy.apply_per_modality_mappings(..., ct_mask_mapping=mappings['ct_mappings'], ...)` |\n", + "| **JSON during load** | One-step workflow, clean notebooks | `longCT, longSPECT, inj, used = tx.imaging_ds.create_studies_with_masks(..., mapping_config=\"roi_mappings.json\")` |\n", + "\n", + "## Next Steps\n", + "\n", + "1. Copy the template: `pytheranostics/data/roi_mappings_template.json`\n", + "2. Customize it for your project's ROI naming conventions\n", + "3. Use one of the three approaches above in your analysis notebooks\n", + "4. Check the diagnostics to ensure mappings are correct\n", + "5. Proceed with dose calculations using the mapped ROI names\n", + "\n", + "## Questions?\n", + "\n", + "Check the inline documentation:\n", + "```python\n", + "help(LongitudinalStudy.apply_per_modality_mappings)\n", + "help(LongitudinalStudy.load_mappings_from_json)\n", + "```" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doodle/documentation/SPECT2SUV.ipynb b/docs/source/tutorials/SPECT2SUV/SPECT2SUV.ipynb similarity index 86% rename from doodle/documentation/SPECT2SUV.ipynb rename to docs/source/tutorials/SPECT2SUV/SPECT2SUV.ipynb index 832972b..478a918 100644 --- a/doodle/documentation/SPECT2SUV.ipynb +++ b/docs/source/tutorials/SPECT2SUV/SPECT2SUV.ipynb @@ -23,9 +23,10 @@ "id": "0c6f9dbd-0afa-4f6a-87ec-a81120df22ec", "metadata": {}, "source": [ + "\n", "### Point to the SPECT image in counts (the one that you want to make quantitative)\n", "\n", - "### and set the output path (the location where you want the QSPECT image to be saved at the end" + "### and set the output path (the location where you want the QSPECT image to be saved at the end)" ] }, { @@ -35,9 +36,21 @@ "metadata": {}, "outputs": [], "source": [ - "spect_counts='/mnt/c/Users/curibe/Nextcloud/BCCancer/CodeRepositories/doodle/doodle/documentation/testimages/016.dcm'\n", + "from pathlib import Path\n", "\n", - "output_path='./test016.dcm'" + "def _find_examples_dir() -> Path:\n", + " candidates = [\n", + " Path.cwd() / \"examples\" / \"data\",\n", + " Path.cwd() / \"docs\" / \"source\" / \"examples\" / \"data\",\n", + " ]\n", + " for candidate in candidates:\n", + " if candidate.exists():\n", + " return candidate\n", + " raise FileNotFoundError(\"Could not locate docs example data directory\")\n", + "\n", + "EXAMPLES_DIR = _find_examples_dir()\n", + "spect_counts = str(EXAMPLES_DIR / \"testimages\" / \"016.dcm\")\n", + "output_path = str(Path.cwd() / \"test016.dcm\")\n" ] }, { @@ -167,4 +180,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst new file mode 100644 index 0000000..58ef2a4 --- /dev/null +++ b/docs/source/tutorials/index.rst @@ -0,0 +1,12 @@ +Tutorials +========= + +Hands-on walkthroughs that demonstrate common PyTheranostics workflows. The +notebooks are rendered directly in the documentation via nbsphinx. + +.. toctree:: + :maxdepth: 1 + + SPECT2SUV/SPECT2SUV + ROI_Mapping_Tutorial/ROI_Mapping_Tutorial + Data_Ingestion_Examples/Data_Ingestion_Examples diff --git a/docs/source/usage/basic_usage.rst b/docs/source/usage/basic_usage.rst new file mode 100644 index 0000000..75abe19 --- /dev/null +++ b/docs/source/usage/basic_usage.rst @@ -0,0 +1,2 @@ +Basic Concepts +============== diff --git a/doodle/AUTHORS.rst b/doodle/AUTHORS.rst deleted file mode 100644 index 1256162..0000000 --- a/doodle/AUTHORS.rst +++ /dev/null @@ -1,5 +0,0 @@ - -Authors -======= - -* Carlos Uribe - https://www.medimagingbytes.com/ diff --git a/doodle/__init__.py b/doodle/__init__.py deleted file mode 100644 index 83541c4..0000000 --- a/doodle/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -from doodle.qc.planar_qc import ( - PlanarQC -) - -from doodle.qc.dosecal_qc import ( - DosecalQC -) - -from doodle.qc.spect_qc import ( - SPECTQC -) - -from doodle.shared.radioactive_decay import ( - decay_act, - get_activity_at_injection -) - -from doodle.shared.evaluation_metrics import ( - perc_diff -) - -from doodle.calibrations.gamma_camera import ( - GammaCamera -) - -from doodle.shared.corrections import ( - tew_scatt -) - - -from doodle.plots.plots import ( - ewin_montage, - monoexp_fit_plots -) - -from doodle.segmentation.tools import ( - rtst_to_mask -) - - -from doodle.fits.fits import ( - monoexp_fun, - biexp_fun, - fit_monoexp, - fit_biexp, - fit_biexp_uptake -) - -from doodle.dicomtools.dicomtools import ( - DicomModify -) \ No newline at end of file diff --git a/doodle/calibrations/__init__.py b/doodle/calibrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/doodle/calibrations/gamma_camera.py b/doodle/calibrations/gamma_camera.py deleted file mode 100644 index c73aa89..0000000 --- a/doodle/calibrations/gamma_camera.py +++ /dev/null @@ -1,168 +0,0 @@ -from doodle.qc.planar_qc import PlanarQC -from doodle.plots.plots import ewin_montage -from doodle.shared.corrections import tew_scatt -from doodle.shared.radioactive_decay import decay_act - -from pathlib import Path -import json -from datetime import datetime - - -# import pydicom -import numpy as np -import pprint - -this_dir=Path(__file__).resolve().parent.parent -CALIBRATIONS_DATA_FILE = Path(this_dir,"data","gamma_camera_sensitivities.json") - -class GammaCamera(PlanarQC): - - def __init__(self,isotope,dicomfile,db_dic,cal_type='planar'): - super().__init__(isotope,dicomfile,db_dic=db_dic,cal_type=cal_type) - - - def get_sensitivity(self,source_id = 'C',**kwargs): - # ser_date = self.ds.SeriesDate - # ser_time = self.ds.SeriesTime - - pix_space = self.ds.PixelSpacing - - #duration of scan in seconds - acq_duration = self.ds.ActualFrameDuration/1000 - - #number of Detectors - ndet = self.ds.NumberOfDetectors - - - # find camera model - if 'site_id' in kwargs: - if kwargs['site_id'] == 'CAVA': - if hasattr(self.ds,'DeviceSerialNumber'): - camera_model = 'Intevo' - else: - camera_model = 'Symbia T' - elif kwargs['site_id'] == 'CAHJ': - if self.ds.ManufacturerModelName == 'Tandem_870_DR': - camera_model = 'Discovery 870' - elif kwargs['site_id'] == 'CAGQ': - if self.ds.ManufacturerModelName == 'Encore2': - camera_model = 'Symbia T6' - elif kwargs['site_id'] == 'CAGA': - if self.ds.ManufacturerModelName == 'Encore2': - camera_model = 'Intevo T6' - elif kwargs['site_id'] == 'CAHN': - if self.ds.ManufacturerModelName == 'Tandem_Discovery_670_ES': - camera_model = 'Discovery 670' - - # find activity of source - if 'site_id' in kwargs: - df = self.db_df['cal_data'] - - df2 = df[(df.site_id == kwargs['site_id']) & (df.source_id == source_id) & (df.cal_type == 'planar') & (df.model == camera_model)] - - A_ref = df2.ref_act_MBq.values[0] - ref_time = df2.ref_time.values[0] - - acq_time = self.ds.AcquisitionTime - acq_date = self.ds.AcquisitionDate - - if '.' in acq_time: - acq_time = np.datetime64(datetime.strptime(f'{acq_date} {acq_time}','%Y%m%d %H%M%S.%f')) - else: - acq_time = np.datetime64(datetime.strptime(f'{acq_date} {acq_time}','%Y%m%d %H%M%S')) - - - # find the time difference in days - if self.isotope_dic['half_life_units'] == 'days': - delta_t = (acq_time - ref_time) / np.timedelta64(1, 'D') - else: - print('check units of decay correction') - - - # Decay the reference activity - A_decayed = decay_act(A_ref,delta_t,self.isotope_dic['half_life']) - print(f"The activity of the source at the time of the scan was {A_decayed} MBq\n") - - #deal with energy windows - nwin = self.ds.NumberOfEnergyWindows - - ewin = {} - - img = self.ds.pixel_array - - for w in range(nwin): - lower = self.ds.EnergyWindowInformationSequence[w].EnergyWindowRangeSequence[0].EnergyWindowLowerLimit - upper = self.ds.EnergyWindowInformationSequence[w].EnergyWindowRangeSequence[0].EnergyWindowUpperLimit - center = round((upper + lower)/2,2) - cent = str(int(center)) - - ewin[cent] = {} - - ewin[cent]['lower'] = lower - ewin[cent]['upper'] = upper - ewin[cent]['center'] = center - ewin[cent]['width'] = upper - lower - ewin[cent]['counts'] = {} - - - # get counts in each window and detector - for ind,i in enumerate(range(0,int(img.shape[0]),2)): - keys = list(ewin.keys()) - - ewin[keys[ind]]['counts']['Detector1'] = np.sum(img[i,:,:]) - ewin[keys[ind]]['counts']['Detector2'] = np.sum(img[i+1,:,:]) - - - win_check = {} - - # TODO: Improve this part - for el in ewin: - for k,w in self.isotope_dic['windows_kev'].items(): - if int(ewin[el]['center']) in range(round(w[0]),round(w[2])): - win_check[k] = ewin[el] - - - ewin_montage(img, ewin) - - print("Info about the different energy windows and detectors:") - pprint.pprint(win_check) - print('\n') - - Cp = tew_scatt(win_check) - - print("Primary counts in each detector") - pprint.pprint(Cp) - - # print(A_decayed,delta_t,ref_time,acq_time) - # Calculate sensitivity in cps/MBq - sensitivity = {k: v / (A_decayed*acq_duration) for k, v in Cp.items()} - sensitivity['Average'] = sum(sensitivity.values()) / len(sensitivity) - - #calculate calibration factor in units of MBq/cps - calibration_factor = {k: 1 / v for k, v in sensitivity.items()} - - self.cal_dic = {} - self.cal_dic[kwargs['site_id']] = {} - self.cal_dic[kwargs['site_id']][camera_model] = {} - self.cal_dic[kwargs['site_id']][camera_model]['manufacturer'] = df2.manufacturer.values[0] - self.cal_dic[kwargs['site_id']][camera_model]['collimator'] = df2.collimator.values[0] - self.cal_dic[kwargs['site_id']][camera_model]['sensitivity'] = sensitivity - self.cal_dic[kwargs['site_id']][camera_model]['calibration_factor'] = calibration_factor - - print('\n') - print("Calibration Results. Sensitivity in cps/MBq. Calibration factor in MBq/cps") - pprint.pprint(self.cal_dic) - - - def calfactor_to_database(self,**kwargs): - if 'site_id' in kwargs: - site_id = kwargs['site_id'] - - with open(CALIBRATIONS_DATA_FILE,'r+') as f: - self.calfactors_dic = json.load(f) - f.seek(0) - if site_id in self.calfactors_dic.keys(): - self.calfactors_dic[site_id].update(self.cal_dic[site_id]) - else: - self.calfactors_dic.update(self.cal_dic) - json.dump(self.calfactors_dic,f,indent=2) \ No newline at end of file diff --git a/doodle/data/gamma_camera_sensitivities.json b/doodle/data/gamma_camera_sensitivities.json deleted file mode 100644 index dbaaa2b..0000000 --- a/doodle/data/gamma_camera_sensitivities.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "CAVA": { - "Symbia T": { - "manufacturer": "Siemens", - "collimator": "MELP", - "sensitivity": { - "Detector1": 9.35637078789132, - "Detector2": 9.161145653532262, - "Average": 9.25875822071179 - }, - "calibration_factor": { - "Detector1": 0.10687904772801055, - "Detector2": 0.10915665330726732, - "Average": 0.10800584442987242 - } - }, - "Intevo": { - "manufacturer": "Siemens", - "collimator": "MELP", - "sensitivity": { - "Detector1": 9.028040553253593, - "Detector2": 9.154348609427013, - "Average": 9.091194581340304 - }, - "calibration_factor": { - "Detector1": 0.11076600665463476, - "Detector2": 0.10923770141003969, - "Average": 0.10999654567426179 - } - } - }, - "CAHJ": { - "Discovery 870": { - "manufacturer": "GE", - "collimator": "MEGP", - "sensitivity": { - "Detector1": 4.495294055657973, - "Detector2": 4.719284937814484, - "Average": 4.607289496736229 - }, - "calibration_factor": { - "Detector1": 0.22245485781766744, - "Detector2": 0.21189650830091714, - "Average": 0.21704735522011215 - } - } - }, - "CAGQ": { - "Symbia T6": { - "manufacturer": "Siemens", - "collimator": "MEGL", - "sensitivity": { - "Detector1": 9.047765308813814, - "Detector2": 9.126775044024312, - "Average": 9.087270176419063 - }, - "calibration_factor": { - "Detector1": 0.11052452908187808, - "Detector2": 0.10956772739290234, - "Average": 0.11004404849708792 - } - } - }, - "CAGA": { - "Intevo T6": { - "manufacturer": "Siemens", - "collimator": "MEGL", - "sensitivity": { - "Detector1": 9.545819213838021, - "Detector2": 9.375061874193033, - "Average": 9.460440544015526 - }, - "calibration_factor": { - "Detector1": 0.10475790265861708, - "Detector2": 0.1066659626804944, - "Average": 0.10570332273084035 - } - } - }, - "CAHN": { - "Discovery 670": { - "manufacturer": "GE", - "collimator": "MEGP", - "sensitivity": { - "Detector1": 5.4473409731355, - "Detector2": 5.504354553570881, - "Average": 5.47584776335319 - }, - "calibration_factor": { - "Detector1": 0.1835758042192828, - "Detector2": 0.18167434351612807, - "Average": 0.18262012444765996 - } - } - } -} \ No newline at end of file diff --git a/doodle/dicomtools/__init__.py b/doodle/dicomtools/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/doodle/dicomtools/dicomtools.py b/doodle/dicomtools/dicomtools.py deleted file mode 100644 index 62a01df..0000000 --- a/doodle/dicomtools/dicomtools.py +++ /dev/null @@ -1,153 +0,0 @@ -import pydicom -import numpy as np -from pydicom.dataset import Dataset -from pydicom.uid import generate_uid -from doodle.shared.radioactive_decay import get_activity_at_injection -import pandas as pd -from datetime import datetime - -class DicomModify(): - - def __init__(self,fname,CF): - self.ds=pydicom.read_file(fname) - self.CF=CF - self.fname = fname - - def make_bqml_suv(self,weight,height,injection_date,pre_inj_activity,pre_inj_time,post_inj_activity,post_inj_time,injection_time,activity_meter_scale_factor,half_life=574300,radiopharmaceutical='Lutetium-PSMA-617',n_detectors = 2): - #Half-life is in seconds - - - - # Siemens has an issue setting up the times. We are using the Acquisition time which is the time of the start of the last bed to harmonize. - if 'siemens' in self.ds.Manufacturer.lower(): - self.ds.SeriesTime = self.ds.AcquisitionTime - self.ds.ContentTime = self.ds.AcquisitionTime - - # Get the frame duration in seconds - frame_duration = self.ds.RotationInformationSequence[0].ActualFrameDuration/1000 - # get number of projections because manufacturers scale by this in the dicomfile - n_proj = self.ds.RotationInformationSequence[0].NumberOfFramesInRotation*n_detectors - #get voxel volume in ml - vox_vol =np.append(np.asarray(self.ds.PixelSpacing),float(self.ds.SliceThickness)) - vox_vol = np.prod(vox_vol/10) - - # Get image in Bq/ml - A = self.ds.pixel_array - A.astype(np.float64) - A = A / (frame_duration * n_proj) * self.CF * 1e6 / vox_vol - - slope,intercept = dicom_slope_intercept(A) - - # update the PixelData - A = np.int16((A - intercept)/slope) # GE dicom is signed so np.int16 - - #bring the new image to the pixel bytes - self.ds.PixelData = A.tobytes() - - self.ds.PixelData - - #update DICOM tags - # self.ds.Units = 'BQML' - self.ds.SeriesDescription = 'QSPECT_' + self.ds.SeriesDescription - - # add the RealWorldValueMappingSequence tag [0040,9096] - self.ds.add_new([0x0040, 0x9096], 'SQ',[]) - self.ds.RealWorldValueMappingSequence += [Dataset(),Dataset()] - - for i in range(2): - self.ds.RealWorldValueMappingSequence[i].RealWorldValueIntercept = intercept - self.ds.RealWorldValueMappingSequence[i].RealWorldValueSlope = slope - self.ds.RealWorldValueMappingSequence[i].RealWorldValueLastValueMapped = int(A.max()) - self.ds.RealWorldValueMappingSequence[i].RealWorldValueFirstValueMapped = int(A.min()) - - self.ds.RealWorldValueMappingSequence[i].LUTLabel = 'BQML' - self.ds.RealWorldValueMappingSequence[i].add_new([0x0040,0x08EA],'SQ',[]) - self.ds.RealWorldValueMappingSequence[i].MeasurementUnitsCodeSequence += [Dataset()] - self.ds.RealWorldValueMappingSequence[i].MeasurementUnitsCodeSequence[0].CodeValue='Bq/ml' - - #add info for SUV - self.ds.PatientWeight= str(weight) # in kg - self.ds.PatientSize = str(height/100) # in m - - self.ds.DecayCorrection = 'START' - self.ds.CorrectedImage.insert(0,'DECY') - - self.ds.add_new([0x0054, 0x0016], 'SQ',[]) - self.ds.RadiopharmaceuticalInformationSequence += [Dataset()] - - - # values for net injected activity and injection date and time - start_datetime, total_injected_activity = get_activity_at_injection(injection_date,pre_inj_activity,pre_inj_time,post_inj_activity,post_inj_time,injection_time,half_life=half_life) - total_injected_activity = total_injected_activity * activity_meter_scale_factor - - - scan_datetime = datetime.strptime(self.ds.SeriesDate + self.ds.SeriesTime,'%Y%m%d%H%M%S.%f') - delta_scan_inj = (scan_datetime - start_datetime).total_seconds()/(60*60*24) - - pre_inj_datetime = datetime.strptime(injection_date + pre_inj_time,'%Y%m%d%H%M') - post_inj_datetime = datetime.strptime(injection_date + post_inj_time,'%Y%m%d%H%M') - - inj_dic = {'patient_id':[self.ds.PatientID],'weight_kg':[weight],'height_m':[height],'pre_inj_activity_MBq':[pre_inj_activity],'pre_inj_datetime':[pre_inj_datetime],'post_inj_activity_MBq':[post_inj_activity],'post_inj_datetime':[post_inj_datetime],'injected_activity_MBq':[total_injected_activity],'injection_datetime':[start_datetime],'scan_datetime':[scan_datetime],'delta_t_days':[delta_scan_inj]} - inj_df = pd.DataFrame(data=inj_dic) - - - self.ds.RadiopharmaceuticalInformationSequence[0].Radiopharmaceutical=radiopharmaceutical - self.ds.RadiopharmaceuticalInformationSequence[0].RadiopharmaceuticalVolume="" - self.ds.RadiopharmaceuticalInformationSequence[0].RadiopharmaceuticalStartTime=start_datetime.strftime("%H%M%S.%f") - self.ds.RadiopharmaceuticalInformationSequence[0].RadionuclideTotalDose=str(round(total_injected_activity, 4)) - self.ds.RadiopharmaceuticalInformationSequence[0].RadionuclideHalfLife=str(half_life) - self.ds.RadiopharmaceuticalInformationSequence[0].RadionuclidePositronFraction='' - self.ds.RadiopharmaceuticalInformationSequence[0].RadiopharmaceuticalStartDateTime=start_datetime.strftime('%Y%m%d%H%M%S.%f') - - # for storing as new series data - sop_ins_uid = self.ds.SOPInstanceUID - b = sop_ins_uid.split('.') - b[-1] = str(int(b[-1]) + 1) - self.ds.SOPInstanceUID = '.'.join(b) - - ser_ins_uid = self.ds.SeriesInstanceUID - b = ser_ins_uid.split('.') - b.pop() - prefix = '.'.join(b) + '.' - self.ds.SeriesInstanceUID = generate_uid(prefix=prefix) - - # self.ds.MediaStorageSOPInstaceUID - return inj_df - - - def save(self): - self.ds.save_as(f"{self.fname.split('.dcm')[0]}_out.dcm") - - - - - - - - -def dicom_slope_intercept(img): - '''This function calculates the slope and intercept for a DICOM image in the way that GE does it. - GE PET images are stored in DICOM files that are signed int16. This allows for a maximum value of 32767. - The slope is calculated such that the maximum value in the pixel array (before multiplying by slope) is 32767. - - Parameters - ---------- - img: numpy array - contains the float values of the image (e.g. MBq/ml in our case) - - Returns - ------- - slope: float - the slope to be set in the dicom header - - intercept: float - the intercept for the dicom header ''' - - - max_val = np.max(img) - min_val = np.min(img) - - slope = np.float32(max(max_val,-min_val)/32767) - intercept = 0 #GE has assigned it to zero - - return float(slope),float(intercept) \ No newline at end of file diff --git a/doodle/dosimetry/__init__.py b/doodle/dosimetry/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/doodle/dosimetry/dosemap_analysis.py b/doodle/dosimetry/dosemap_analysis.py deleted file mode 100644 index d27bede..0000000 --- a/doodle/dosimetry/dosemap_analysis.py +++ /dev/null @@ -1,66 +0,0 @@ -import matplotlib.pyplot as plt -import gatetools as gt -import itk -import numpy as np -import SimpleITK as sitk -import argparse -class Dosemap: - def __init__(self, df, patient_id, cycle, dosemap, roi_masks_resampled, organlist): - self.df = df - self.patient_id = patient_id - self.cycle = int(cycle) - self.dosemap = dosemap - self.roi_masks_resampled = roi_masks_resampled - self.organlist = organlist - - - def image_visualisation(self, image): - fig, axs = plt.subplots(1, 3, figsize=(15, 5)) - - axs[0].imshow(image[:, :, 50]) - axs[0].set_title('Slice at index 50') - axs[1].imshow(image[65, :, :].T) - axs[1].set_title('Slice at index 100') - axs[2].imshow(image[:,47, :]) - axs[2].set_title('Slice at index 47') - - plt.tight_layout() - plt.show() - - - def show_mean_statistics(self): - self.ad_mean = {} - for organ in self.organlist: - mask = self.roi_masks_resampled[organ] - self.ad_mean[organ] = self.dosemap[mask].mean() - print(f'{organ}', self.ad_mean[organ]) - #print(self.ad_mean) - - def show_max_statistics(self): - for organ in self.organlist: - mask = self.roi_masks_resampled[organ] - x = self.dosemap[mask].max() - print(f'{organ}', x) - - def dose_volume_histogram(self): - doseimage = self.dosemap.astype(float) - doseimage = itk.image_from_array(doseimage) - - for organ in self.organlist: - x = self.roi_masks_resampled[organ] - itkVol = x.astype(float) - itkVol = itk.GetImageFromArray(itkVol) - print(type(itkVol)) - zip =itk.LabelStatisticsImageFilter.New(doseimage) - zip = zip.SetLabelInput() - organ_data = gt.createDVH(doseimage, itkVol) - print(organ_data) - - def save_dataframe(self): - print(self.df) - patient_data = self.df[(self.df['patient_id'] == self.patient_id) & (self.df['cycle'] == self.cycle)] - patient_data['mean_ad[Gy]']= patient_data['organ'].map(self.ad_mean) - print(patient_data) - self.df.loc[(self.df['patient_id'] == self.patient_id) & (self.df['cycle'] == self.cycle)] = patient_data - print(self.df) - self.df.to_csv("/mnt/y/Sara/PR21_dosimetry/df.csv") diff --git a/doodle/dosimetry/dvk.py b/doodle/dosimetry/dvk.py deleted file mode 100644 index 9170d51..0000000 --- a/doodle/dosimetry/dvk.py +++ /dev/null @@ -1,157 +0,0 @@ -import numpy as np -import matplotlib.pyplot as plt -import skimage.io -import scipy -import skimage.io as io -from scipy import signal - - -class DoseVoxelKernel: - def __init__(self, TIAMBqs, CT): - self.TIAMBqs = np.asarray(TIAMBqs, dtype=np.float64) - self.CT = CT - - def kernel(self): - vsv = np.fromfile('/mnt/c/Users/skurkowska/Desktop/PR21_dosimetry/convolution/DK_softtissueICRP_mGyperMBqs.img', dtype='float32') - vsv = vsv.reshape((51,51,51)) - self.vsv = np.asarray(vsv, dtype=np.float64) - - - def convolution(self): - conv_img = signal.fftconvolve(self.TIAMBqs, self.vsv, mode='same', axes=None) - np.save('convolved_vol.npy', conv_img) - #plt.imshow(conv_img[60,:,:]) - #print(type(conv_img)) - self.conv_img= np.transpose(conv_img, (1, 2, 0)) - return self.conv_img - - def weighting(self, image): - xdim = self.CT.shape[0] - ydim = self.CT.shape[1] - zdim = self.CT.shape[2] - - # Assumed density for simulated voxel s values [g/ccm] --> voxel mass [g] - rho_ICRPsoft = 1.00 - - # Start HU to density conversion based on CT - # Taken from patient-HUmaterials.db - # Conversion according to Schneider et al. 2000 - # RHOMAT=zeros(xdim,ydim,zdim); - - - - # Initialize the RHOMAT array with the same shape as CT - RHOMAT = np.zeros_like(self.CT) - for i in range(xdim): - for j in range(ydim): - for k in range(zdim): - if self.CT[i, j, k] <= -1050: - self.CT[i, j, k] = -1050 - # taken from patient-HUmaterials.db from GATE, rho in g/ccm - if self.CT[i, j, k] >= -1050: - if self.CT[i, j, k] <= -1050: - RHOMAT[i, j, k] = 0.00121 - elif self.CT[i, j, k] <= -950: - RHOMAT[i, j, k] = 0.102695 - elif self.CT[i, j, k] <= -852.884: - RHOMAT[i, j, k] = 0.202695 - elif self.CT[i, j, k] <= -755.769: - RHOMAT[i, j, k] = 0.302695 - elif self.CT[i, j, k] <= -658.653: - RHOMAT[i, j, k] = 0.402695 - elif self.CT[i, j, k] <= -561.538: - RHOMAT[i, j, k]=0.502695 - elif self.CT[i, j, k] <= -464.422: - RHOMAT[i, j, k]=0.602695 - elif self.CT[i, j, k] <= -367.306: - RHOMAT[i, j, k]=0.702695 - elif self.CT[i, j, k] <= -270.191: - RHOMAT[i, j, k]=0.802695 - elif self.CT[i, j, k] <= -173.075: - RHOMAT[i, j, k]=0.880021 - elif self.CT[i, j, k] <= -120: - RHOMAT[i, j, k]=0.926911 - elif self.CT[i, j, k] <= -82: - RHOMAT[i, j, k]=0.957382 - elif self.CT[i, j, k] <= -52: - RHOMAT[i, j, k]=0.984277 - elif self.CT[i, j, k] <= -22: - RHOMAT[i, j, k]=1.01117 - elif self.CT[i, j, k] <= 8: - RHOMAT[i, j, k]=1.02955 - elif self.CT[i, j, k] <= 19: - RHOMAT[i, j, k]=1.0616 - elif self.CT[i, j, k] <= 80: - RHOMAT[i, j, k]=1.1199 - elif self.CT[i, j, k] <= 120: - RHOMAT[i, j, k]=1.11115 - elif self.CT[i, j, k] <= 200: - RHOMAT[i, j, k]=1.16447 - elif self.CT[i, j, k] <= 300: - RHOMAT[i, j, k]=1.22371 - elif self.CT[i, j, k] <= 400: - RHOMAT[i, j, k]=1.28295 - elif self.CT[i, j, k] <= 500: - RHOMAT[i, j, k]=1.34219 - elif self.CT[i, j, k] <= 600: - RHOMAT[i, j, k]=1.40142 - elif self.CT[i, j, k] <= 700: - RHOMAT[i, j, k]=1.46066 - elif self.CT[i, j, k] <= 800: - RHOMAT[i, j, k]=1.5199 - elif self.CT[i, j, k] <= 900: - RHOMAT[i, j, k]=1.57914 - elif self.CT[i, j, k] <= 1000: - RHOMAT[i, j, k]=1.63838 - elif self.CT[i, j, k] <= 1100: - RHOMAT[i, j, k]=1.69762 - elif self.CT[i, j, k] <= 1200: - RHOMAT[i, j, k]=1.75686 - elif self.CT[i, j, k] <= 1300: - RHOMAT[i, j, k]=1.8161 - elif self.CT[i, j, k] <= 1400: - RHOMAT[i, j, k]=1.87534 - elif self.CT[i, j, k] <= 1500: - RHOMAT[i, j, k]=1.94643 - elif self.CT[i, j, k] <= 1640: - RHOMAT[i, j, k]=2.03808 - elif self.CT[i, j, k] <= 1807.5: - RHOMAT[i, j, k]=2.13808 - elif self.CT[i, j, k] <= 1975.01: - RHOMAT[i, j, k]=2.23808 - elif self.CT[i, j, k] <= 2142.51: - RHOMAT[i, j, k]=2.33509 - elif self.CT[i, j, k] <= 2300: - RHOMAT[i, j, k]=2.4321 - elif self.CT[i, j, k] <= 2467.5: - RHOMAT[i, j, k]=2.5321 - elif self.CT[i, j, k] <= 2635.01: - RHOMAT[i, j, k]=2.6321 - elif self.CT[i, j, k] <= 2802.51: - RHOMAT[i, j, k]=2.7321 - elif self.CT[i, j, k] <= 2970.02: - RHOMAT[i, j, k]=2.79105 - elif self.CT[i, j, k] <= 3000: - RHOMAT[i, j, k]=2.9 - elif self.CT[i, j, k] <= 4000: - RHOMAT[i, j, k] = 2.9 - elif self.CT[i, j, k] <= 4001: - RHOMAT[i, j, k] = 2.9 - # RHOMAT now contains the converted values - - weighting = np.zeros_like(RHOMAT) - for i in range(xdim): - for j in range(ydim): - for k in range(zdim): - weighting[i, j, k] = rho_ICRPsoft / RHOMAT[i, j, k] - - - # Calculate weighted image - image_weighted = np.zeros_like(image) - for i in range(xdim): - for j in range(ydim): - for k in range(zdim): - image_weighted[i, j, k] = image[i, j, k] * weighting[i, j, k] - - return image_weighted - diff --git a/doodle/dosimetry/mc.py b/doodle/dosimetry/mc.py deleted file mode 100644 index 8e34d1f..0000000 --- a/doodle/dosimetry/mc.py +++ /dev/null @@ -1,29 +0,0 @@ - -import os - -class MonteCarlo: - def __init__(self, n_cpu, n_primaries, output_dir): - self.n_cpu = n_cpu - self.n_primaries = n_primaries - self.output_dir = output_dir - - def split_simulations(self): - n_primaries_per_mac = int(self.n_primaries / self.n_cpu) - - - with open("./main_template.mac",'r') as mac_file: - filedata = mac_file.read() - - for i in range(0, self.n_cpu): - new_mac = filedata - - new_mac = new_mac.replace('distrib-SPLIT.mhd',f'distrib_SPLIT_{i+1}.mhd') - new_mac = new_mac.replace('stat-SPLIT.txt',f'stat__SPLIT_{i+1}.txt') - new_mac = new_mac.replace('XXX',str(n_primaries_per_mac)) - - with open(f'{self.output_dir}/main_normalized_{i+1}.mac','w') as output_mac: - output_mac.write(new_mac) - - def run_MC(self): - os.system(f"bash {self.output_dir}/runsimulation1.sh {self.output_dir} {self.n_cpu}") - \ No newline at end of file diff --git a/doodle/dosimetry/olinda.py b/doodle/dosimetry/olinda.py deleted file mode 100644 index 6208a6b..0000000 --- a/doodle/dosimetry/olinda.py +++ /dev/null @@ -1,43 +0,0 @@ -from os import path -import pandas as pd -import numpy as np - - -class Olinda: - def __init__(self, df): - self.df = df - - - def phantom_data(self): - lesion_df = self.df[self.df['organ'].str.contains('Lesion', case=False, na=False)] - - phrases_to_exclude = ['Femur', 'Humerus', 'Reference', 'TotalTumorBurden', 'Kidney_L_m', 'Kidney_R_m', 'Lesion'] - phrases_to_exclude.extend([f'L{i}' for i in range(10)]) # L(any number) - # Replace "Kidney_R_a" and "Kidney_L_a" with "Kidneys" in the 'organ' column - self.df['organ'] = self.df['organ'].replace(['Kidney_R_a', 'Kidney_L_a'], 'Kidneys') - # Group by 'organ' and aggregate values (e.g., sum) - self.df = self.df.groupby('organ').agg({'tiac_h': 'sum'}).reset_index() - - self.df['organ'] = self.df['organ'].replace(['ParotidglandL', 'ParotidglandR', 'SubmandibularglandL', 'SubmandibularglandR'], 'Salivary Glands') - # Group by 'organ' and aggregate values (e.g., sum) - self.df = self.df.groupby('organ').agg({'tiac_h': 'sum'}).reset_index() - self.df['organ'] = self.df['organ'].replace('Bladder_Experimental', 'Urinary Bladder Contents') - - - - self.df = self.df[~self.df['organ'].str.contains('|'.join(phrases_to_exclude), case=False, na=False)] - print(self.df) - - self.organlist = self.df['organ'].unique() - this_dir=path.dirname(__file__) - PHANTOM_PATH = path.join(this_dir,'phantomdata') - self.phantom_mass = pd.read_csv(path.join(PHANTOM_PATH,'human_phantom_masses.csv')) - print(np.array(self.phantom_mass['Organ'])) - not_inphantom=[] - for org in self.organlist: - if org not in np.array(self.phantom_mass['Organ']): - not_inphantom.append(org) - print('These organs from the biodi are not modelled in the phantom\n{}'.format(not_inphantom)) - - def template_data(self): - TEMPLATE_PATH = path.join(this_dir,"olindaTemplates") \ No newline at end of file diff --git a/doodle/dosimetry/patientdosimetry.py b/doodle/dosimetry/patientdosimetry.py deleted file mode 100644 index bb88042..0000000 --- a/doodle/dosimetry/patientdosimetry.py +++ /dev/null @@ -1,343 +0,0 @@ -# %% -import os -import numpy as np -import pandas as pd -import matplotlib.pyplot as plt -from scipy import integrate -from scipy.optimize import curve_fit -from datetime import datetime -import re -import glob -import pydicom -from rt_utils import RTStructBuilder -import gatetools as gt -import SimpleITK as sitk -import itk -from skimage import io -from skimage.transform import resize -from skimage import img_as_bool -from doodle.fits.fits import monoexp_fun, fit_monoexp, find_a_initial, fit_biexp_uptake -from doodle.plots.plots import monoexp_fit_plots, biexp_fit_plots - -# %% -itk.LabelStatisticsImageFilter.GetTypes() -# %% -class PatientDosimetry: - def __init__(self, df, patient_id, cycle, isotope, CT, SPECTMBq, roi_masks_resampled, activity_tp1_df, inj_timepoint1, activity_tp2_df = None, inj_timepoint2 = None): - self.df = df - self.patient_id = patient_id - self.cycle = int(cycle) - self.isotope = isotope - self.activity_tp1_df = activity_tp1_df - self.activity_tp2_df = activity_tp2_df - self.inj_timepoint1 = inj_timepoint1 - self.inj_timepoint2 = inj_timepoint2 - self.CT = CT - self.SPECTMBq = SPECTMBq - self.roi_masks_resampled = roi_masks_resampled - - def dataframe(self): - # Calculate the sum of 'volume_ml' for observations other than 'WBCT' - filtered_df = self.activity_tp1_df[(self.activity_tp1_df['Contour'] == 'TotalTumorBurden') | - (self.activity_tp1_df['Contour'] == 'Kidney_L_a') | - (self.activity_tp1_df['Contour'] == 'Kidney_R_a') | - (self.activity_tp1_df['Contour'] == 'Liver') | - (self.activity_tp1_df['Contour'] == 'ParotidglandL') | - (self.activity_tp1_df['Contour'] == 'ParotidglandR') | - (self.activity_tp1_df['Contour'] == 'Spleen') | - (self.activity_tp1_df['Contour'] == 'Skeleton') | - (self.activity_tp1_df['Contour'] == 'Bladder_Experimental') | - (self.activity_tp1_df['Contour'] == 'SubmandibularglandL') | - (self.activity_tp1_df['Contour'] == 'SubmandibularglandR')] - - - # Specify the values for the new observation - rob_observation = { - 'Contour': 'ROB', - 'Series Date': 'x', - 'Integral Total (BQML*ml)': self.activity_tp1_df.loc[self.activity_tp1_df['Contour'] == 'WBCT', 'Integral Total (BQML*ml)'] - filtered_df['Integral Total (BQML*ml)'].sum(), - 'Max (BQML)': 'x', - 'Mean Ratio (-)': 'x', - 'Total (BQML)': 'x', - 'Volume (ml)': self.activity_tp1_df.loc[self.activity_tp1_df['Contour'] == 'WBCT', 'Volume (ml)'] - filtered_df['Volume (ml)'].sum(), - 'Voxel Count (#)': 'x' - } - #activity_tp1_df[len(activity_tp1_df)] = rob_observation - rob_observation = pd.DataFrame([rob_observation]) - self.activity_tp1_df = pd.concat([self.activity_tp1_df, rob_observation], ignore_index=True) - - # Specify the values for the new observation - rob_observation = { - 'Contour': 'ROB', - 'Series Date': 'x', - 'Integral Total (BQML*ml)': self.activity_tp2_df.loc[self.activity_tp2_df['Contour'] == 'WBCT', 'Integral Total (BQML*ml)'] - filtered_df['Integral Total (BQML*ml)'].sum(), - 'Max (BQML)': 'x', - 'Mean Ratio (-)': 'x', - 'Total (BQML)': 'x', - 'Volume (ml)': self.activity_tp2_df.loc[self.activity_tp2_df['Contour'] == 'WBCT', 'Volume (ml)'] - filtered_df['Volume (ml)'].sum(), - 'Voxel Count (#)': 'x' - } - rob_observation = pd.DataFrame([rob_observation]) - self.activity_tp2_df = pd.concat([self.activity_tp2_df, rob_observation], ignore_index=True) - - self.new_data = pd.DataFrame() - #################################### Output dataframe ######################################### - if self.patient_id in self.df['patient_id'].values: - if self.cycle in self.df['cycle'].values: - print(f"Patient {self.patient_id} cycle0{self.cycle} is already on the list!") - elif self.cycle not in self.df['cycle'].values: - self.new_data = pd.DataFrame({ - 'patient_id': [self.patient_id] * len(self.activity_tp1_df), - 'cycle': [self.cycle] * len(self.activity_tp1_df), - 'organ': self.activity_tp1_df['Contour'].tolist(), - 'volume_ml': self.activity_tp1_df['Volume (ml)'].tolist() - }) - else: - self.new_data = pd.DataFrame({ - 'patient_id': [self.patient_id] * len(self.activity_tp1_df), - 'cycle': [self.cycle] * len(self.activity_tp1_df), - 'organ': self.activity_tp1_df['Contour'].tolist(), - 'volume_ml': self.activity_tp1_df['Volume (ml)'].tolist() - }) - -# # Calculate the sum of 'volume_ml' for observations other than 'WBCT' -# sum_without_wbct = self.new_data[self.new_data['organ'] != 'WBCT']['volume_ml'].sum() -# -# # Specify the values for the new observation -# rob_observation = { -# 'patient_id': self.patient_id, -# 'cycle': self.cycle, -# 'organ': 'ROB', -# 'volume_ml': self.new_data.loc[self.new_data['organ'] == 'WBCT', 'volume_ml'].values[0] - sum_without_wbct -# } -# self.new_data.loc[len(self.new_data)] = rob_observation - - - - print(self.new_data) - self.organslist = self.activity_tp1_df['Contour'].unique() - return self.organslist - - def fitting(self): - ################################# Isotope information ######################################### - if self.isotope == '177Lu': - half_life = 159.528 * 3600 #s - decayconst = np.log(2)/half_life - else: - print('Isotope currently not implemented in the code. Add it in the class code.') - - ###################################### Variables ############################################## - t = [self.inj_timepoint1.loc[0, 'delta_t_days'], self.inj_timepoint2.loc[0, 'delta_t_days']] - ts = np.array([i * 24 * 3600 for i in t]) # seconds - inj_activity = self.inj_timepoint1.loc[0, 'injected_activity_MBq'] # MBq - inj_activity = inj_activity * 10**6 # Bq - - ###################################### Fitting ############################################## - for index,row in self.new_data.iterrows(): - a_tp1 = self.activity_tp1_df.iloc[index][['Integral Total (BQML*ml)']].values - a_tp2 = self.activity_tp2_df.iloc[index][['Integral Total (BQML*ml)']].values - activity = [a_tp1, a_tp2] - activity = np.array([float(arr[0]) for arr in activity]) # reduce dimensons and extract values -# popt,tt,yy,residuals = fit_monoexp(ts,activity,deayconst,monoguess=(1e6,1e-5)) -# monoexp_fit_plots(ts / (3600 * 24) ,activity / 10**6,tt / (3600 * 24),yy / 10**6,row['organ'],popt[2],residuals) -# plt.show() -# elif function == 2: -# popt,tt,yy,residuals = fit_biexp_uptake(ts,activity,decayconst,uptakeguess=(6,1e-3 , 1e-3)) -# biexp_fit_plots(ts / (3600 * 24), activity / 10**6, tt / (3600 * 24),yy / 10**6,row['organ'],popt[2],residuals) -# #monoexp_fit_plots(ts, activity, tt,yy,row['organ'],popt[2],residuals) -# #plt.show() - #else: - # print('Function unknown') - popt,tt,yy,residuals = fit_monoexp(ts,activity,decayconst,monoguess=(1e6,1e-5)) - monoexp_fit_plots(ts / (3600 * 24) ,activity / 10**6,tt / (3600 * 24),yy / 10**6,row['organ'],popt[2],residuals) - plt.show() - self.new_data.at[index, 'lamda_eff_1/s'] = popt[1] - self.new_data.at[index, 'a0_Bq'] = popt[0] - self.new_data.at[index, 'tia_bqs'] = popt[0]/popt[1] - self.new_data.at[index, 'tiac_h'] = (popt[0]/popt[1])/(inj_activity * 3600) - - ########################### Dictionary with lamda eff of each VOIs ############################ - self.lamda_eff_dict = {} - for organ in self.organslist: - organ_data = self.new_data[self.new_data['organ'] == organ] - lamda_eff = organ_data['lamda_eff_1/s'].iloc[0] - self.lamda_eff_dict[organ] = lamda_eff - - return self.new_data - - def takefitfromcycle1_andintegrate(self): - cycle1_df = self.df[(self.df['patient_id'] == self.patient_id) & (self.df['cycle'] == 1)] - t = [self.inj_timepoint1.loc[0, 'delta_t_days']] - ts = np.array([i * 24 * 3600 for i in t]) # seconds - inj_activity = self.inj_timepoint1.loc[0, 'injected_activity_MBq'] # MBq - inj_activity = inj_activity * 10**6 # Bq - self.lamda_eff_dict = {} - a0_Bq_dict = {} - for organ in self.organslist: - if organ in cycle1_df['organ'].values: - # Get the lambda_eff value for the organ from cycle 1 - lamda_eff = cycle1_df.loc[cycle1_df['organ'] == organ, 'lamda_eff_1/s'].iloc[0] - self.lamda_eff_dict[organ] = np.float128(lamda_eff) - a_tp1 = self.activity_tp1_df.loc[self.activity_tp1_df['Contour'] == organ, 'Integral Total (BQML*ml)'].iloc[0] - aO_Bq = find_a_initial(a_tp1 , lamda_eff, ts) - a0_Bq_dict[organ] = aO_Bq[0] - else: - self.lamda_eff_dict[organ] = None - print(f"The {organ} is not found in the cycle 1.") - - self.new_data['lamda_eff_1/s'] = self.new_data['organ'].map(self.lamda_eff_dict) - self.new_data['a0_Bq'] = self.new_data['organ'].map(a0_Bq_dict) - self.new_data['tia_bqs'] = self.new_data['a0_Bq'] / self.new_data['lamda_eff_1/s'] - self.new_data['tiac_h'] = (self.new_data['a0_Bq'] / self.new_data['lamda_eff_1/s'])/(inj_activity * 3600) - - return self.new_data - - def image_visualisation(self, image): - fig, axs = plt.subplots(1, 3, figsize=(15, 5)) - axs[0].imshow(image[:, :, 50]) - axs[0].set_title('Slice at index 50') - axs[1].imshow(image[100, :, :].T) - axs[1].set_title('Slice at index 100') - axs[2].imshow(image[:,47, :]) - axs[2].set_title('Slice at index 47') - - plt.tight_layout() - plt.show() - - def create_TIA(self): - - ############################################################################################## - ####################################### OPTION 1 ############################################# - ############################################################################################## -# self.TIA = np.zeros((self.SPECTMBq.shape[0], self.SPECTMBq.shape[1], self.SPECTMBq.shape[2])) -# if self.cycle == 1: -# time = self.inj_timepoint2.loc[0, 'delta_t_days'] * 24 * 3600 # s -# else: -# time = self.inj_timepoint1.loc[0, 'delta_t_days'] * 24 * 3600 # s -# -# organs = [organ for organ in self.organslist if organ != "ROB"] -# print(organs) -# for organ in organs: -# self.TIA[self.roi_masks_resampled[organ]] = self.SPECTMBq[self.roi_masks_resampled[organ]] * np.exp(self.lamda_eff_dict[organ] * time) / self.lamda_eff_dict[organ] - - ############################################################################################## - ####################################### OPTION 1 ############################################# - ############################################################################################## -# self.TIA = np.zeros((self.SPECTMBq.shape[0], self.SPECTMBq.shape[1], self.SPECTMBq.shape[2])) -# if self.cycle == 1: -# time = self.inj_timepoint2.loc[0, 'delta_t_days'] * 24 * 3600 # s -# else: -# time = self.inj_timepoint1.loc[0, 'delta_t_days'] * 24 * 3600 # s - - - #organs = [organ for organ in self.organslist if organ != "WBCT"] - #last_element = organs.pop() # Remove the ROB - #organs.insert(0, last_element) # Insert it at the beginning - #organs = np.array(organs) - #print(organs) - #for organ in organs: - # self.TIA[self.roi_masks_resampled[organ]] = self.SPECTMBq[self.roi_masks_resampled[organ]] * np.exp(self.lamda_eff_dict[organ] * time) / self.lamda_eff_dict[organ] - - - ############################################################################################## - ####################################### OPTION 2 ############################################# - ############################################################################################## - - if self.cycle == 1: - time = self.inj_timepoint2.loc[0, 'delta_t_days'] * 24 * 3600 # s - else: - time = self.inj_timepoint1.loc[0, 'delta_t_days'] * 24 * 3600 # s - - TIA_WB = np.zeros((self.SPECTMBq.shape[0], self.SPECTMBq.shape[1], self.SPECTMBq.shape[2])) - TIA_organs = np.zeros((self.SPECTMBq.shape[0], self.SPECTMBq.shape[1], self.SPECTMBq.shape[2])) - - TIA_WB[self.roi_masks_resampled['WBCT']] = self.SPECTMBq[self.roi_masks_resampled['WBCT']] * np.exp(self.lamda_eff_dict['WBCT'] * time) / self.lamda_eff_dict['WBCT'] - organs = [organ for organ in self.organslist if organ not in ["WBCT", "ROB", "Liver_Reference", "TotalTumorBurden", "Kidney_L_m", 'Kidney_R_m']] - index_skeleton = organs.index('Skeleton') - organs.pop(index_skeleton) - - # Insert 'Skeleton' at the beginning of the list - organs.insert(0, 'Skeleton') - - print(organs) - for organ in organs: - TIA_organs[self.roi_masks_resampled[organ]] = self.SPECTMBq[self.roi_masks_resampled[organ]] * np.exp(self.lamda_eff_dict[organ] * time) / self.lamda_eff_dict[organ] - - print(TIA_WB.shape) - print(TIA_organs.shape) - TIA_ROB = TIA_WB - TIA_organs - self.TIA = TIA_ROB + TIA_organs - - ############################################################################################## - ####################################### OPTION 3 ############################################# - ############################################################################################## - -# if self.cycle == 1: -# time = self.inj_timepoint2.loc[0, 'delta_t_days'] * 24 * 3600 # s -# else: -# time = self.inj_timepoint1.loc[0, 'delta_t_days'] * 24 * 3600 # s -# -# TIA_WB = np.zeros((self.SPECTMBq.shape[0], self.SPECTMBq.shape[1], self.SPECTMBq.shape[2])) -# TIA_organs = np.zeros((self.SPECTMBq.shape[0], self.SPECTMBq.shape[1], self.SPECTMBq.shape[2])) -# -# TIA_WB[self.roi_masks_resampled['WBCT']] = self.SPECTMBq[self.roi_masks_resampled['WBCT']] * np.exp(self.lamda_eff_dict['WBCT'] * time) / self.lamda_eff_dict['WBCT'] -# organs = [organ for organ in self.organslist if organ not in ["WBCT", "ROB", "Liver_Reference", "TotalTumorBurden", "Kidney_L_m", 'Kidney_R_m']] -# index_skeleton = organs.index('Skeleton') -# organs.pop(index_skeleton) -# -# # Insert 'Skeleton' at the beginning of the list -# organs.insert(0, 'Skeleton') -# print(organs) -# for organ in organs: -# TIA_organs[self.roi_masks_resampled[organ]] = self.SPECTMBq[self.roi_masks_resampled[organ]] * np.exp(self.lamda_eff_dict[organ] * time) / self.lamda_eff_dict[organ] -# -# TIA_ROB = TIA_WB - TIA_organs -# TIA_ROB_value = np.sum(np.sum(np.sum(TIA_ROB))) -# ROB_no_voxels = np.sum(np.sum(np.sum(self.roi_masks_resampled['ROB']))) -# -# TIA_value_per_voxel = TIA_ROB_value / ROB_no_voxels -# TIA_ROB[self.roi_masks_resampled['ROB']] = TIA_value_per_voxel -# -# self.TIA = TIA_ROB + TIA_organs - - - - return self.TIA - - def flip_images(self): - self.CTp = np.transpose(self.CT, (2, 0, 1)) - self.TIAp = np.transpose(self.TIA, (2, 0, 1)) - print('CT image:') - self.image_visualisation(self.CTp) - print('TIA image:') - self.image_visualisation(self.TIAp) - - return self.TIAp - - def normalise_TIA(self): - self.total_acc_A = np.sum(np.sum(np.sum(self.TIAp))) - self.source_normalized = self.TIAp / self.total_acc_A - - def save_images_and_df(self): - self.df = pd.concat([self.df, self.new_data], ignore_index=True) - self.df = self.df.dropna(subset=['patient_id']) - print(self.df) - self.df.to_csv("/mnt/y/Sara/PR21_dosimetry/df.csv") - - self.CTp = np.array(self.CTp, dtype=np.float32) - image = sitk.GetImageFromArray(self.CTp) - image.SetSpacing([4.7952, 4.7952, 4.7952]) - sitk.WriteImage(image, f'/mnt/y/Sara/PR21_dosimetry/{self.patient_id}/cycle0{self.cycle}/MC/data/CT.mhd') - - self.source_normalized = np.array(self.source_normalized, dtype=np.float32) - image2 = sitk.GetImageFromArray(self.source_normalized) - image2.SetSpacing([4.7952, 4.7952, 4.7952]) - sitk.WriteImage(image2, f'/mnt/y/Sara/PR21_dosimetry/{self.patient_id}/cycle0{self.cycle}/MC/data/Source_normalized.mhd') - - folder = f'/mnt/y/Sara/PR21_dosimetry/{self.patient_id}/cycle0{self.cycle}/MC/output' - image_path = os.path.join(folder, 'TotalAccA.txt') - with open(image_path, 'w') as fileID: - fileID.write('%.2f' % self.total_acc_A) - - -# %% diff --git a/doodle/dosimetry/pr21_dataframes.py b/doodle/dosimetry/pr21_dataframes.py deleted file mode 100644 index 2e0aef5..0000000 --- a/doodle/dosimetry/pr21_dataframes.py +++ /dev/null @@ -1,281 +0,0 @@ -# %% -import os -import numpy as np -import pandas as pd -import matplotlib.pyplot as plt -from scipy import integrate -from scipy.optimize import curve_fit -from datetime import datetime -import re -import glob -import pydicom -from rt_utils import RTStructBuilder -import gatetools as gt -import scipy -import SimpleITK as sitk -from skimage import io -from skimage.transform import resize -from skimage import img_as_bool -from doodle.fits.fits import monoexp_fun, fit_monoexp -from doodle.plots.plots import monoexp_fit_plots -from pathlib import Path -import sys -import tempfile -from pydicom.dataset import FileDataset, FileMetaDataset -from pydicom.uid import UID - -# %% -def getinputdata(patient_id, cycle): - folder = "/mnt/y/Sara/PR21_dosimetry" - - ####################################################################################### - # Activities from Patient statistics from MIM - mimcsv_tp1 = f"{folder}/activity/{patient_id}_cycle0{cycle}_tp1.csv" - activity_tp1_df = pd.read_csv(mimcsv_tp1) - activity_tp1_df['Contour'] = activity_tp1_df['Contour'].str.replace(' ', '') - activity_tp1_df['Contour'] = activity_tp1_df['Contour'].str.replace('-', '_') - activity_tp1_df['Contour'] = activity_tp1_df['Contour'].str.replace('(', '_') - activity_tp1_df['Contour'] = activity_tp1_df['Contour'].str.replace(')', '') - - if cycle == '1': - mimcsv_tp2 = f"{folder}/activity/{patient_id}_cycle0{cycle}_tp2.csv" - activity_tp2_df = pd.read_csv(mimcsv_tp2) - activity_tp2_df['Contour'] = activity_tp2_df['Contour'].str.replace(' ', '') - activity_tp2_df['Contour'] = activity_tp2_df['Contour'].str.replace('-', '_') - activity_tp2_df['Contour'] = activity_tp2_df['Contour'].str.replace('(', '_') - activity_tp2_df['Contour'] = activity_tp2_df['Contour'].str.replace(')', '') - else: - pass - ####################################################################################### - - - # Retrieve information about date of acquisition - # It is crucial, because name of files in incjection .csv are named with acq date - acq1 = activity_tp1_df.loc[1, 'Series Date'] - date_object = datetime.strptime(acq1, "%Y-%m-%d") - data1 = date_object.strftime("%Y%m%d") - if cycle == '1': - acq2 = activity_tp2_df.loc[1, 'Series Date'] - date_object = datetime.strptime(acq2, "%Y-%m-%d") - data2 = date_object.strftime("%Y%m%d") - else: - pass - - # injection timepoint data have in their name hyphen CAVA-00X, so here i create patient id with hyphen - # Use regular expression to insert a hyphen after every group of letters - x_with_hyphen = re.sub(r'([A-Za-z]+)', r'\1-', patient_id) - # Remove the trailing hyphen (if any) - if x_with_hyphen.endswith('-'): - x_with_hyphen = x_with_hyphen[:-1] - - ####################################################################################### - # Load injection timepoint data - injection_folder = f"{folder}/injection_timepoints" - file_path = f"{injection_folder}/PR21-{x_with_hyphen}.{data1}.injection.info.csv" - inj_timepoint1 = pd.read_csv(file_path) - if cycle == '1': - file_path = f"{injection_folder}/PR21-{x_with_hyphen}.{data2}.injection.info.csv" - inj_timepoint2 = pd.read_csv(file_path) - else: - pass - ####################################################################################### - - ####################################################################################### - # Load CT - CT_folder = f"{folder}/{patient_id}/cycle0{cycle}/CT" # this CT is resampled - files = [] - for fname in glob.glob(CT_folder + "/*.dcm", recursive=False): - files.append(pydicom.dcmread(fname)) - pixel_arrays = [slice.pixel_array for slice in files] - CT = np.stack(pixel_arrays, axis=-1) - slices = [] - skipcount = 0 - for f in files: - if hasattr(f, "SliceLocation"): - slices.append(f) - else: - skipcount = skipcount + 1 - slices = sorted(slices, key=lambda s: s.SliceLocation) - - CT_xysize = slices[0].PixelSpacing - CT_zsize = slices[0].SliceThickness - sp = [CT_xysize[0], CT_xysize[1], CT_zsize] - img_shape = list(slices[0].pixel_array.shape) - img_shape.append(len(slices)) - img3d = np.zeros(img_shape) - for i, s in enumerate(slices): - img2d = s.pixel_array - img3d[:, :, i] = img2d - CT = np.squeeze(img3d) - CT = CT - 1024 # Caution! Loaded CT has not accounted for shift in HUs! 'Rescale Intercept': -1024 - ####################################################################################### - - - ####################################################################################### - # Load SPECT - SPECT_folder = f"{folder}/{patient_id}/cycle0{cycle}/SPECT" - SPECT_hdr = pydicom.read_file(glob.glob(os.path.join(SPECT_folder, '*.dcm'))[0]) # I added [0] because glob.glob(os.path.join(folder_path, '*.dcm')), produce a list. not a path, but the 1st element of the list is a path - SPECT_xdim=SPECT_hdr.Rows - SPECT_ydim=SPECT_hdr.Columns - SPECT_zdim=SPECT_hdr.NumberOfFrames - SPECT_xsize=SPECT_hdr.PixelSpacing[0] - SPECT_ysize=SPECT_hdr.PixelSpacing[1] - SPECT_zsize=SPECT_hdr.SpacingBetweenSlices - SPECT_volume_voxel=SPECT_xsize*SPECT_ysize*SPECT_zsize/1000 - - SPECT = SPECT_hdr.pixel_array - SPECT = np.transpose(SPECT, (1, 2, 0)) # from (233,128,128) to (128,128,233) - SPECT.astype(np.float64) - scalefactor = SPECT_hdr.RealWorldValueMappingSequence[0].RealWorldValueSlope - SPECT = SPECT * scalefactor # in Bq/ml - SPECTMBq = SPECT * SPECT_volume_voxel / 1E6 # in MBq - ####################################################################################### - - - ####################################################################################### - # Load RT - patient_path = f"{folder}/{patient_id}/cycle0{cycle}" - RT = RTStructBuilder.create_from( - dicom_series_path = glob.glob(os.path.join(patient_path, 'CT2'))[0], # this CT is not resampled, the one to which RT structures are attached - rt_struct_path = glob.glob(os.path.join(patient_path, 'CT2/rt-struct.dcm'))[0] - ) - roi_masks = {} - roi_names = RT.get_roi_names() - # Adjusting names so they much dataframe - for roi_name in roi_names: - cleaned_roi_name = roi_name.replace(" ", "") - cleaned_roi_name = cleaned_roi_name.replace('-', '_') - cleaned_roi_name = cleaned_roi_name.replace('(', '_') - cleaned_roi_name = cleaned_roi_name.replace(')', '') - mask = RT.get_roi_mask_by_name(roi_name) - roi_masks[f"{cleaned_roi_name}"] = mask - # Resampling to 128, 128, 233 - roi_masks_resampled = {} - organslist = activity_tp1_df['Contour'].unique() - for organ in organslist: - mask_image = roi_masks[organ] # Access the mask using the 'organ' variable - resized = img_as_bool(resize(mask_image, (128, 128, 233))) # Resize to (128, 128, 233) - roi_masks_resampled[organ] = resized - RT_xdim = resized.shape[0] - RT_ydim = resized.shape[1] - RT_zdim = resized.shape[2] - - - # Create th ROI ROB (WBCT-everything else) - all_organs = np.zeros((RT_xdim, RT_ydim, RT_zdim)) - organs = [organ for organ in organslist if organ != "WBCT"] - for organ in organs: - all_organs += roi_masks_resampled[organ] - rob = roi_masks_resampled['WBCT'] - all_organs - rob = rob.astype(bool) - roi_masks_resampled['ROB'] = rob - - - ####################################################################################### - - # Define your data for three observations: SPECT, CT, RT - data = [ - {' ': 'SPECT', 'xdim': SPECT_xdim, 'ydim': SPECT_ydim, 'zdim': SPECT_zdim, 'xsize': SPECT_xsize, 'ysize': SPECT_ysize, 'zsize': SPECT_zsize}, - {' ': 'CT', 'xdim': CT.shape[0], 'ydim': CT.shape[1], 'zdim': CT.shape[2], 'xsize': CT_xysize[0], 'ysize': CT_xysize[1], 'zsize': CT_zsize}, - {' ': 'RT', 'xdim': RT_xdim, 'ydim': RT_ydim, 'zdim': RT_zdim, 'xsize': "", 'ysize': "", 'zsize': ""} - ] - - # Create a DataFrame - df = pd.DataFrame(data) - - # Print the summary table - print(df) - - if cycle == '1': - return activity_tp1_df, activity_tp2_df, inj_timepoint1, inj_timepoint2, CT, SPECTMBq, roi_masks_resampled - else: - return activity_tp1_df, inj_timepoint1, CT, SPECTMBq, roi_masks_resampled - - - -# %% -# Luke's function from qurit github - -def find_first_entry_containing_substring(list_of_attributes, substring, dtype=np.float32): - line = list_of_attributes[np.char.find(list_of_attributes, substring)>=0][0] - if dtype == np.float32: - return np.float32(line.replace('\n', '').split(':=')[-1]) - elif dtype == str: - return (line.replace('\n', '').split(':=')[-1].replace(' ', '')) - elif dtype == int: - return int(line.replace('\n', '').split(':=')[-1].replace(' ', '')) - -def intf2dcm(headerfile, folder, patient_id, cycle): - # Interfile attributes - with open(headerfile) as f: - headerdata = f.readlines() - headerdata = np.array(headerdata) - dim1 = find_first_entry_containing_substring(headerdata, 'matrix size [1]', int) - dim2 = find_first_entry_containing_substring(headerdata, 'matrix size [2]', int) - dim3 = find_first_entry_containing_substring(headerdata, 'matrix size [3]', int) - dx = find_first_entry_containing_substring(headerdata, 'scaling factor (mm/pixel) [1]', np.float32) - dy = find_first_entry_containing_substring(headerdata, 'scaling factor (mm/pixel) [2]', np.float32) - dz = find_first_entry_containing_substring(headerdata, 'scaling factor (mm/pixel) [3]', np.float32) - number_format = find_first_entry_containing_substring(headerdata, 'number format', str) - num_bytes_per_pixel = find_first_entry_containing_substring(headerdata, 'number of bytes per pixel', np.float32) - imagefile = find_first_entry_containing_substring(headerdata, 'name of data file', str) - pixeldata = np.fromfile(os.path.join(str(Path(headerfile).parent), imagefile), dtype=np.float32) - dose_scaling_factor = np.max(pixeldata) / (2**16 - 1) - pixeldata /= dose_scaling_factor - pixeldata = pixeldata.astype(np.int32) - path = f'{folder}/{patient_id}/cycle0{cycle}/MC' - #ds = pydicom.read_file(glob.glob(os.path.join(path, 'spect.dcm'))[0]) - ds = pydicom.read_file(glob.glob(os.path.join(path, 'template.dcm'))[0]) - #ds = pydicom.read_file(os.path.join(str(Path(os.path.realpath(__file__)).parent), "template.dcm")) - ds.BitsAllocated = 32 - ds.Rows = dim1 - ds.Columns = dim2 - ds.PixelRepresentation = 0 - ds.NumberOfFrames = dim3 - ds.PatientName = f'PR21-CAVA-0004' - ds.PatientID = f'PR21-CAVA-0004' - ds.PixelSpacing = [dx, dy] - ds.SliceThickness = dz - ds.Modality = 'NM' - #ds.ReferencedImageSequence[0].ReferencedSOPClassUID = 'UN' - ds.ReferencedImageSequence[0].ReferencedSOPInstanceUID = '1.2.826.0.1.3680043.10.740.6048439480987740658090176328874005953' - ds.FrameOfReferenceUID = '1.2.826.0.1.3680043.10.740.2362138874319727035222927285105155066' - #ds.ReferencedRTPlanSequence[0].ReferencedSOPClassUID = 'UN' - #ds.ReferencedRTPlanSequence[0].ReferencedSOPInstanceUID = 'UN' - ds.PixelData = pixeldata.tobytes() - - - print(ds.ReferencedImageSequence[0].ReferencedSOPClassUID) - print(ds.ReferencedImageSequence[0].ReferencedSOPInstanceUID) - print(ds.FrameOfReferenceUID) - print(ds.ReferencedRTPlanSequence[0].ReferencedSOPClassUID) - print(ds.ReferencedRTPlanSequence[0].ReferencedSOPInstanceUID) - ds.save_as("doseimage_try.dcm") - return ds -# %% -def image_visualisation(image): - fig, axs = plt.subplots(1, 3, figsize=(15, 5)) - axs[0].imshow(image[:, :, 120]) - axs[0].set_title('Slice at index 50') - axs[1].imshow(image[120, :, :].T) - axs[1].set_title('Slice at index 100') - axs[2].imshow(image[:,64, :]) - axs[2].set_title('Slice at index 47') - plt.tight_layout() - plt.show() - -def getdosemapdata(patient_id, cycle): - folder = "/mnt/y/Sara/PR21_dosimetry" - - ###### TO DO: merge files - - headerfile_path = f'{folder}/{patient_id}/cycle0{cycle}/MC/output/DoseimageGy_MC.hdr' - print(headerfile_path) - ds = intf2dcm(headerfile_path, folder, patient_id, cycle) - Dosemap = ds.pixel_array - Dosemap = np.rot90(Dosemap) - Dosemap = np.transpose(Dosemap, (0,2,1)) - return Dosemap - - -# %% diff --git a/doodle/fits/__init__.py b/doodle/fits/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/doodle/fits/fits.py b/doodle/fits/fits.py deleted file mode 100644 index 3e0d145..0000000 --- a/doodle/fits/fits.py +++ /dev/null @@ -1,145 +0,0 @@ -import numpy as np -from numpy import exp,log -import pandas as pd - -import matplotlib.pylab as plt - -from scipy import integrate -from scipy.optimize import curve_fit - - -def monoexp_fun(t,a,b): - return a*exp(-b*t) - -def biexp_fun(t, a, b,c,d): - return a*exp(-b*t) + c*exp(-d*t) - -# def triexp_fun(x,a,b,c,d,e): -# decayconst = 0.004344987591895751 -# return a*exp(-b*x) + c*exp(-d*x) +e*exp(-decayconst*x) - -def find_a_initial(f, b, t): - return f * exp(b * t) - -def get_residuals(t,a,skip_points,popt,eq='monoexp'): - - if eq == 'monoexp': - residuals = a[skip_points:] - monoexp_fun(t[skip_points:],popt[0],popt[1]) - elif eq == 'biexp': - residuals = a[skip_points:] - biexp_fun(t[skip_points:],popt[0],popt[1],popt[2],popt[3]) - ss_res = np.sum(residuals**2) - ss_tot = np.sum((a[skip_points:]-np.mean(a[skip_points:]))**2) - r_squared = 1 - (ss_res / ss_tot) - - return r_squared, residuals - - - -def fit_monoexp(t,a,decayconst,skip_points=0,ignore_weights=True,monoguess=(1,1),maxev=100000,limit_bounds=False): - - if ignore_weights: - weights = None - else: - weights = sigmas - - #monoexponential fit - # bounds show that the minimum decay is with physical decay, can't decay slower. (decayconst to inf) - - if limit_bounds: - upper_bound = decayconst - else: - upper_bound = decayconst * 1e6 - - - if weights: - popt, pconv = curve_fit(monoexp_fun,t[skip_points:],a[skip_points:],sigma=weights[skip_points:], - p0=monoguess,bounds=([0,upper_bound],np.inf),maxfev=maxev) - else: - print("Im here") - popt, pconv = curve_fit(monoexp_fun,t[skip_points:],a[skip_points:],sigma=weights,p0=monoguess, - maxfev=maxev) - - - [r_squared, residuals] = get_residuals(t,a,skip_points,popt,eq='monoexp') - - popt = np.append(popt,r_squared) - - # create times for displaying the function - tt = np.linspace(0,t.max()*2.5,1000) - yy = monoexp_fun(tt,*popt[:2]) - # yy = monoexp_fun(tt[tt>=t[skip_points]],*popt[:2]) - - # popt_df = pd.DataFrame(popt).T - # popt_df.columns = ['A0','lambda_eff','R2'] - - return popt,tt,yy,residuals - - -def fit_biexp(t,a,decayconst,skip_points=0,ignore_weights=True,append_zero=True,biguess=(1,1,1,0.1),maxev=100000): - - - if ignore_weights: - weights = None - else: - weights = sigmas - weights = np.append(1,weights) - - - if weights: - popt, pconv = curve_fit(biexp_fun,t[skip_points:],a[skip_points:],sigma=weights[skip_points:], - p0=biguess,bounds=([0,decayconst,0,decayconst],np.inf),maxfev=maxev) - else: - popt, pconv = curve_fit(biexp_fun,t[skip_points:],a[skip_points:], - p0=biguess,bounds=([0,decayconst,0,decayconst],np.inf),maxfev=maxev) - - - [r_squared, residuals] = get_residuals(t,a,skip_points,popt,eq='biexp') - - popt = np.append(popt,r_squared) - - # create times for displaying the function - tt = np.linspace(0,t.max()*2.5,1000) - yy = biexp_fun(tt,*popt[:4]) - # yy = monoexp_fun(tt[tt>=t[skip_points]],*popt[:2]) - - # popt_df = pd.DataFrame(popt).T - # popt_df.columns = ['A0','lambda_eff','R2'] - - return popt,tt,yy,residuals - - - -def fit_biexp_uptake(t,a,decayconst,skip_points=0,ignore_weights=True,append_zero=True,uptakeguess=(1,1,-1,1),maxev=100000): - - # Append point (0,0) if flag set to true. - if append_zero: - t = np.append(0,t) - a = np.append(0,a) - if ignore_weights: - weights = None - else: - weights = sigmas - weights = np.append(1,weights) - - #monoexponential fit - # bounds show that the minimum decay is with physical decay, can't decay slower. (decayconst to inf) - - - popt, pconv = curve_fit(biexp_fun,t,a,sigma=weights, - p0=uptakeguess,bounds=([0,decayconst,-np.inf,decayconst],[np.inf,np.inf,0,np.inf]),maxfev=maxev) - - - [r_squared, residuals] = get_residuals(t,a,skip_points,popt,eq='biexp') - - popt = np.append(popt,r_squared) - - # create times for displaying the function - tt = np.linspace(0,t.max()*2.5,1000) - yy = biexp_fun(tt,*popt[:4]) - # yy = monoexp_fun(tt[tt>=t[skip_points]],*popt[:2]) - - # popt_df = pd.DataFrame(popt).T - # popt_df.columns = ['A0','lambda_eff','R2'] - - return popt,tt,yy,residuals - diff --git a/doodle/plots/__init__.py b/doodle/plots/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/doodle/plots/plots.py b/doodle/plots/plots.py deleted file mode 100644 index c582e4a..0000000 --- a/doodle/plots/plots.py +++ /dev/null @@ -1,118 +0,0 @@ -import matplotlib.pyplot as plt - - -def ewin_montage(img,ewin): - ''' - img: image data - - ewin: energy window dictionary - - ''' - - plt.figure(figsize=(22,6)) - for ind,i in enumerate(range(0,int(img.shape[0]),2)): - keys = list(ewin.keys()) - - # Top row Detector 1 - plt.subplot(2,6,ind+1) - plt.imshow(img[i,:,:]) - plt.title(f'Detector1 {ewin[keys[ind]]["center"]} keV') - plt.colorbar() - - - # # Bottom row Detector 2 - plt.subplot(2,6,ind+7) - plt.imshow(img[i+1,:,:]) - plt.title(f'Detector2 {ewin[keys[ind]]["center"]} keV') - plt.colorbar() - - - plt.tight_layout() - - - - - -def monoexp_fit_plots(t,a,tt,yy,organ,r_squared,residuals,skip_points=0,sigmas=None,**kwargs): - fig, axes = plt.subplots(1, 3, figsize=(10,4)) - - if sigmas: - axes[0].errorbar(t,a,yerr=sigmas,fmt='o') - # axes[1,0].errorbar(t,a,yerr=sigmas,fmt='o') - else: - axes[0].plot(t,a,'o',color='#1f77b4',markeredgecolor='black') - - if skip_points: - axes[0].plot(tt[tt>=t[skip_points]],yy) - axes[1].semilogy(tt[tt>=t[skip_points]],yy) - else: - axes[0].plot(tt,yy) - axes[1].semilogy(tt,yy) - - axes[0].set_title('{} Mono-Exponential'.format(organ)) - axes[0].set_xlabel('t (days)') - axes[0].set_ylabel('A (MBq)') - axes[0].text(0.6,0.85,'$R^2={:.4f}$'.format(r_squared),transform=axes[0].transAxes) - - axes[1].semilogy(t[skip_points:],a[skip_points:],'o',color='#1f77b4',markeredgecolor='black') - axes[1].set_xlabel('t (days)') - axes[1].set_ylabel('A (MBq)') - axes[1].set_title('{} Mono-Exponential'.format(organ)) - - axes[2].plot(t[skip_points:],residuals,'o') - axes[2].set_title('Residuals') - axes[2].set_xlabel('t (days)') - axes[2].set_ylabel('A (MBq)') - - plt.tight_layout() - - if 'save_path' in kwargs: - plt.savefig(f"{kwargs['save_path']}/{organ}_monoexp_fit.png",dpi=300) - - - -def biexp_fit_plots(t,a,tt,yy,organ,r_squared,residuals,skip_points=0,sigmas=None,**kwargs): - fig, axes = plt.subplots(1, 3, figsize=(10,4)) - - if sigmas: - axes[0].errorbar(t,a,yerr=sigmas,fmt='o') - # axes[1,0].errorbar(t,a,yerr=sigmas,fmt='o') - else: - axes[0].plot(t,a,'o',color='#1f77b4',markeredgecolor='black') - - if skip_points: - axes[0].plot(tt[tt>=t[skip_points]],yy) - axes[1].semilogy(tt[tt>=t[skip_points]],yy) - else: - axes[0].plot(tt,yy) - axes[1].semilogy(tt,yy) - - axes[0].set_title('{} Bi-Exponential'.format(organ)) - axes[0].set_xlabel('t (days)') - axes[0].set_ylabel('A (MBq)') - axes[0].text(0.6,0.85,'$R^2={:.4f}$'.format(r_squared),transform=axes[0].transAxes) - - - - axes[1].semilogy(t[skip_points:],a[skip_points:],'o',color='#1f77b4',markeredgecolor='black') - axes[1].set_xlabel('t (days)') - axes[1].set_ylabel('A (MBq)') - axes[1].set_title('{} Bi-Exponential'.format(organ)) - - axes[2].plot(t[skip_points:],residuals,'o') - axes[2].set_title('Residuals') - axes[2].set_xlabel('t (days)') - axes[2].set_ylabel('A (MBq)') - - - axes[0].set_xlim(left=0) - axes[0].set_ylim(bottom=0) - - axes[1].set_xlim(left=0) - axes[1].set_ylim(a[1:].min()*0.8,a.max()*1.1) - - - plt.tight_layout() - - if 'save_path' in kwargs: - plt.savefig(f"{kwargs['save_path']}/{organ}_biexpuptake_fit.png",dpi=300) diff --git a/doodle/qc/__init__.py b/doodle/qc/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/doodle/qc/dosecal_qc.py b/doodle/qc/dosecal_qc.py deleted file mode 100644 index c71cc3b..0000000 --- a/doodle/qc/dosecal_qc.py +++ /dev/null @@ -1,121 +0,0 @@ -from doodle.qc.qc import QC -from doodle.shared.radioactive_decay import decay_act -from doodle.shared.evaluation_metrics import perc_diff - -import pandas as pd -import numpy as np - -class DosecalQC(QC): - - def __init__(self,isotope,db_dic,cal_type='dc'): - super().__init__(isotope,db_dic=db_dic,cal_type=cal_type) - - - def check_calibration(self,accepted_percent=1.5,accepted_recovery=(97,103)): - - # keep a flag to accept or reject depending on the different tests. Default is to accept (1): - # If something fails it will be changed to 2 if needs to verify and 3 if it completely fails - - self.accepted_calibration = 1 - - self.append_to_summary(f'QC for Dose Calibrator Calibration of {self.isotope}:\t \n\n') - - #check that the expected activity matches the decay of the: - - # Shipped Sources - self.check_source_decay(accepted_percent=accepted_percent) - - # 20 ml syringe - self.check_syringe_recovery() - - # check if reported recovery matches the calculated one and if they fall within the approved range - - if (self.db_df['cal_data']['recovery_calculated'] == self.db_df['cal_data']['reported_recovery']).all(axis=None): - if ((accepted_recovery[0] <= self.db_df['cal_data']['recovery_calculated']) & (accepted_recovery[1] >= self.db_df['cal_data']['recovery_calculated'])).all(axis=None): - self.append_to_summary(f'The reported recovery matches the calculated recovery and falls within {accepted_recovery[0]} % and {accepted_recovery[1]} %.\t OK\n\n') - - else: - self.accepted_calibration = 3 - self.append_to_summary(f'The reported recovery matches the calculated recovery but is out of the acceptable range of {accepted_recovery[0]} % to {accepted_recovery[1]} %.\t FAIL\n') - self.append_to_summary(f'\nPlease see below:\t \n\n') - mismatch_df = self.db_df['cal_data'][self.db_df['cal_data']['recovery_calculated'] != self.db_df['cal_data']['reported_recovery']] - cols_show = ['performed_by','manufacturer','model','source_id','ref_act_MBq','decayed_ref','Ai_syr_MBq','Af_syr_MBq','As_syr_MBq','Am_syr_MBq','reported_recovery','recovery_calculated'] - self.append_to_summary(f"{mismatch_df[cols_show].to_string()}\t ") - - else: - if ((accepted_recovery[0] <= self.db_df['cal_data']['recovery_calculated']) & (accepted_recovery[1] >= self.db_df['cal_data']['recovery_calculated'])).all(axis=None): - self.accepted_calibration = 2 - self.append_to_summary(f'One or more reported recovery does not match the calculated recovery. However, the calculated recovery is within the accepted range of {accepted_recovery[0]} % and {accepted_recovery[1]} %.\t VERIFY\n') - # self.append_to_summary(f'Please see below:\t \n\n') - else: - self.accepted_calibration = 3 - self.append_to_summary(f'One or more reported recovery does not match the calculated recovery and the calculated recovery is out of the acceptable range of {accepted_recovery[0]} % to {accepted_recovery[1]} %.\t FAIL\n') - # self.append_to_summary(f'Please see below:\t \n\n') - # print(self.db_df['cal_data'].columns) - mismatch_df = self.db_df['cal_data'][self.db_df['cal_data']['recovery_calculated'] != self.db_df['cal_data']['reported_recovery']] - cols_show = ['manufacturer','model','source_id','delta_t','ref_act_MBq','decayed_ref','Ae_MBq','decay_perc_diff','Am_MBq','Ai_syr_MBq','Af_syr_MBq','As_syr_MBq','Am_syr_MBq','reported_recovery','recovery_calculated', 'syringe_activity_calculated'] - #print(f"\n\n{mismatch_df[cols_show]}") - print(f"\n\n{self.db_df['cal_data'][cols_show]}") - - - # if self.accepted_calibration == 1: - # self.append_to_summary(f"The following are the accepted calibration numbers for the dose calibrators submitted:\n\n") - self.accepted_calnum_df = self.db_df['cal_data'][['manufacturer','model','source_id','final_cal_num']].groupby(['manufacturer','model','source_id']).sum() - # self.append_to_summary(f"{summary_df.to_string()}") - - self.print_summary() - - - def check_source_decay(self,accepted_percent): - # find the shipped sources - sources = self.db_df['shipped_data'].source_id.unique() - - for s in sources: - #find the reference time of the shipped source - ref_act = self.db_df['shipped_data'][self.db_df['shipped_data'].source_id == s]['A_ref_MBq'] - ref_time = self.db_df['shipped_data'][self.db_df['shipped_data'].source_id == s]['ref_datetime'] - - - # calculate the delta time of the calibration of the shipped source and the measurement - self.db_df['cal_data'].loc[self.db_df['cal_data'].source_id == s,'delta_t'] = (self.db_df['cal_data'][self.db_df['cal_data']['source_id']==s]['measurement_datetime'] - ref_time.values[0]) / np.timedelta64(1,'D') - self.db_df['cal_data'].loc[self.db_df['cal_data'].source_id == s,'ref_act_MBq'] = ref_act.values[0] - - - self.db_df['cal_data']['decayed_ref'] = np.nan - - # decay correct reference sources to measurement time series - self.db_df['cal_data'].decayed_ref = self.db_df['cal_data'].apply(lambda row: decay_act(row.ref_act_MBq,row.delta_t,self.isotope_dic['half_life']),axis=1) - - # Check that decay has been applied correctly. calculate perc_diff - self.db_df['cal_data']['decay_perc_diff'] = perc_diff(self.db_df['cal_data']['Ae_MBq'],self.db_df['cal_data'].decayed_ref) - - #check recovery with the calculated decayed activity - self.db_df['cal_data']['recovery_calculated'] = (self.db_df['cal_data'].Am_MBq / self.db_df['cal_data']['decayed_ref'] *100).round(1) - - # check if any perc_diff are higher than accepted - if (self.db_df['cal_data']['decay_perc_diff'].abs()>accepted_percent).any(axis=None): - self.accepted_calibration = 2 - self.append_to_summary(f'One or more of the sources decay correction are off by more than {accepted_percent} %.\t MISMATCH\n\n') - # print(f"{self.db_df['cal_data'][['source_id','measurement_datetime','delta_t','ref_act_MBq','Am_MBq','Ae_MBq','decayed_ref','decay_perc_diff']].to_string()}") - - else: - self.append_to_summary(f'All the sources decay correction are within {accepted_percent} % of the expected.\t OK\n\n') - - - def check_syringe_recovery(self,syringe_name='syringe_20_mL'): - - self.db_df['cal_data'].loc[self.db_df['cal_data'].source_id == syringe_name,'syringe_activity_calculated'] = self.db_df['cal_data']['Ai_syr_MBq'] - self.db_df['cal_data']['Af_syr_MBq'] - self.db_df['cal_data'].loc[self.db_df['cal_data'].source_id == syringe_name,'recovery_calculated'] = (self.db_df['cal_data']['Am_syr_MBq'] / self.db_df['cal_data']['syringe_activity_calculated'] * 100).round(1) - - # check if the expected activity in syringe is correct - syr_df = self.db_df['cal_data'][self.db_df['cal_data'].source_id == syringe_name] - - - if (syr_df.syringe_activity_calculated == syr_df.As_syr_MBq).all(axis=None): - self.append_to_summary(f'The reported activity expected in the syringe matches the calculation.\t OK\n\n') - else: - self.accepted_calibration = 2 - self.append_to_summary(f'The reported activity expected in the syringe does not match the calculation.\t MISMATCH\n\n') - # mismatch_df = syr_df[syr_df['syringe_activity_calculated'] != syr_df['As_syr_MBq']] - # cols_show = ['performed_by','manufacturer','model','source_id','Ai_syr_MBq','Af_syr_MBq','As_syr_MBq','syringe_activity_calculated'] - # self.append_to_summary(f"{mismatch_df[cols_show].to_string()} \n\n") \ No newline at end of file diff --git a/doodle/qc/planar_qc.py b/doodle/qc/planar_qc.py deleted file mode 100644 index e12601d..0000000 --- a/doodle/qc/planar_qc.py +++ /dev/null @@ -1,65 +0,0 @@ -from doodle.qc.qc import QC -import pydicom -import numpy as np -import pandas as pd - -class PlanarQC(QC): - - def __init__(self,isotope,dicomfile,db_dic,cal_type='planar'): - super().__init__(isotope,db_dic=db_dic,cal_type=cal_type) - self.ds = pydicom.dcmread(dicomfile) - - - def check_windows_energy(self): - - self.append_to_summary(f'QC for planar scan of {self.isotope}:\n\n') - - self.check_camera_parameters() - - - - self.print_summary() - - - def check_camera_parameters(self): - camera_manufacturer = self.ds.Manufacturer - camera_model = self.ds.ManufacturerModelName - acquisition_date = self.ds.AcquisitionDate - acquisition_time = self.ds.AcquisitionTime - modality = self.ds.Modality - duration =(self.ds.ActualFrameDuration / 1000) /60 - rows = self.ds.Rows - cols = self.ds.Columns - zoom = self.ds.DetectorInformationSequence[0].ZoomFactor - - self.append_to_summary(f'CAMERA: {camera_manufacturer} {camera_model}\t \n') - self.append_to_summary(f'MODALITY: {modality}\t \n') - self.append_to_summary(f'Scan performed on: {acquisition_date} at {acquisition_time}\t \n\n') - - #check duration - if round(duration) == self.isotope_dic['planar']['duration']: - self.append_to_summary(f'SCAN DURATION: {round(duration)} minutes\tOK\n\n') - else: - self.append_to_summary(f'SCAN DURATION: {duration} minutes (should be {self.isotope_dic["planar"]["duration"]} mins)\tMISMATCH\n\n') - - #check windows - self.window_check_df = self.window_check(type='planar') - - #check collimator - accepted_collimators = self.isotope_dic['accepted_collimators'] - if (len(self.db_df['cal_data']['collimator'].unique()) == 1) & (self.db_df['cal_data']['collimator'].unique()[0] in accepted_collimators): - self.append_to_summary(f"COLLIMATOR: {self.db_df['cal_data']['collimator'].unique()[0]}\t OK\n\n") - else: - self.append_to_summary(f"COLLIMATOR: {self.db_df['cal_data']['collimator'].unique()[0]} is not in the accepted collimator list.\t VERIFY\n\n") - - #check matrix size - if [rows,cols] == self.isotope_dic['planar']['matrix']: - self.append_to_summary(f'MATRIX SIZE: {rows} x {cols}\t OK\n') - else: - self.append_to_summary(f'MATRIX SIZE: {rows} x {cols} (should be {self.isotope_dic["planar"]["matrix"][0]} x {self.isotope_dic["planar"]["matrix"][1]}\t MISMATCH\n') - - #check zoom - if zoom == self.isotope_dic['planar']['zoom']: - self.append_to_summary(f'ZOOM: {zoom}\t OK\n\n') - else: - self.append_to_summary(f'ZOOM: {zoom} (should be {self.isotope_dic["planar"]["zoom"]}\tMISMATCH\n\n') \ No newline at end of file diff --git a/doodle/qc/qc.py b/doodle/qc/qc.py deleted file mode 100644 index e385984..0000000 --- a/doodle/qc/qc.py +++ /dev/null @@ -1,208 +0,0 @@ -from pathlib import Path -import json -import numpy as np -import pandas as pd -from io import StringIO -from doodle.shared.radioactive_decay import decay_act -from doodle.shared.evaluation_metrics import perc_diff - - -this_dir=Path(__file__).resolve().parent.parent -ISOTOPE_DATA_FILE = Path(this_dir,"data","isotopes.json") - - -class QC: - def __init__(self,isotope,**kwargs): - - ''' - - **kwargs: - db_dic: containing three keys - db_file - sheet_names (list) - header - site_id - ''' - - self.db_df = {} - - with open(ISOTOPE_DATA_FILE) as f: - self.isotope_dic = json.load(f) - - self.isotope = isotope - self.isotope_dic = self.isotope_dic[isotope] - self.summary = '' - - if 'db_dic' in kwargs: - db_file = kwargs['db_dic']['db_file'] - sheet_names = kwargs['db_dic']['sheet_names'] - header = kwargs['db_dic']['header'] - if 'site_id' in kwargs['db_dic']: - site_id = kwargs['db_dic']['site_id'] - - if 'cal_type' in kwargs: - cal_type = kwargs['cal_type'] - - db_df = pd.read_excel(db_file,sheet_name=sheet_names,header=header) - - if 'calibration_data' in db_df.keys(): - cal_forms = db_df[sheet_names[0]] - - #convert columns of date and time to datetime - cols = ['measurement_datetime', 'ref_time'] - - for c in cols: - cal_forms[c] = pd.to_datetime(cal_forms[c], format='%Y%m%d %H:%M') - - self.db_df['cal_data'] = cal_forms - - else: - cal_forms = db_df[sheet_names[0]] - ref_shipped = db_df[sheet_names[1]] - - #convert columns of date and time to datetime - cal_forms['measurement_datetime'] = pd.to_datetime(cal_forms['measurement_datetime'], format='%Y%m%d %H:%M') - ref_shipped['ref_datetime'] = pd.to_datetime(ref_shipped['ref_datetime'], format='%Y%m%d %H:%M') - - # only look at the center being qualified and the type of calibration necessary - if 'site_id' in kwargs['db_dic']: - self.db_df['cal_data'] = cal_forms[(cal_forms.site_id == site_id) & (cal_forms.cal_type == cal_type)] - self.db_df['shipped_data'] = ref_shipped[(ref_shipped.site_id == site_id)] - else: - self.db_df['cal_data'] = cal_forms - self.db_df['shipped_data'] = ref_shipped - - - def window_check(self,win_perdiff_max=2,type='planar'): - - if type == 'planar': - ds = self.ds - elif type == 'spect': - ds = self.recon_ds - elif type == 'raw': - ds = self.proj_ds - - win = [] - for i in range(len(ds.EnergyWindowInformationSequence)): - low = ds.EnergyWindowInformationSequence[i].EnergyWindowRangeSequence[0].EnergyWindowLowerLimit - upper = ds.EnergyWindowInformationSequence[i].EnergyWindowRangeSequence[0].EnergyWindowUpperLimit - center = (low + upper) / 2 - - win.append((low,center,upper)) - - # check if the expected windows are found in the image being analyzed - win_check = {} - # first find if the centers correspond to the different expected windows and labeled those windows accordingly - for el in win: - for k,w in self.isotope_dic['windows_kev'].items(): - if int(el[1]) in range(round(w[0]),round(w[2])): - win_check[k] = el - - # find which expected windows are not in the current dataset, and which windows are in both - missing_win_keys = list(set(self.isotope_dic['windows_kev'].keys()) - set(win_check)) - common_win_keys = set(self.isotope_dic['windows_kev'].keys()).intersection(set(win_check)) - - if type != 'spect': - if missing_win_keys: - self.append_to_summary(f'The dataset is missing a window for {missing_win_keys}.\tMISMATCH\n') - else: - self.append_to_summary('The dataset contains all the expected energy windows.\tOK\n') - else: - if 'photopeak' in common_win_keys: - self.append_to_summary(f"The reconstructed image is for the correct photopeak, centered at {win_check['photopeak'][1]} keV.\tOK\n") - - - # find differences between common windows - win_perc_dif = {} - for k in common_win_keys: - expected = np.array(self.isotope_dic['windows_kev'][k]) - configured = np.array(win_check[k]) - - win_perc_dif[k] = np.round((configured - expected) / expected * 100,2) - - - win_perc_dif.update(dict.fromkeys(missing_win_keys,np.nan)) - - protocol_df = pd.DataFrame.from_dict(self.isotope_dic['windows_kev']).T - - win_df = pd.DataFrame.from_dict(win_check).T - - perc_diff_df = pd.DataFrame.from_dict(win_perc_dif).T - - arrays = [['expected','expected','expected','configured','configured','configured','perc_diff','perc_diff','perc_diff'],['min','center','upper','min','center','upper','min','center','upper']] - - if type != 'spect': - # check if any perc_diff are higher than 2% - if (perc_diff_df.abs()>win_perdiff_max).any(axis=None): - self.append_to_summary(f'There are differences in the energy window settings that are higher than {win_perdiff_max} %.\nPlease see below:\t MISMATCH\n\n') - # self.append_to_summary(f'{self.window_check_df.to_string()}') - elif (perc_diff_df.abs()!=0).any(axis=None): - self.append_to_summary(f'There are differences in the energy window settings but are minimal and acceptable.\t VERIFY\n\n') - # self.append_to_summary(f'{self.window_check_df.to_string()}') - else: - self.append_to_summary(f'The energy window settings are set as expected.\t OK\n\n') - # self.append_to_summary(f'{self.window_check_df.to_string()}') - - window_check_df = pd.concat([protocol_df,win_df, perc_diff_df],axis=1) - window_check_df.columns = pd.MultiIndex.from_arrays(arrays) - - if type == 'spect': - window_check_df.dropna(inplace=True) - - return window_check_df - - - def append_to_summary(self,text): - self.summary = self.summary + text - - def print_summary(self): - # print(self.summary) - summary = StringIO(self.summary) - self.summary_df = pd.read_csv(summary,sep='\t') - # print(self.summary_df.columns[0]) - cols = self.summary_df.columns[0] - # print(cols) - #self.summary_df.style.set_properties(subset=[cols],**{'width': '500px'},**{'text-align': 'left'}).hide_index() # https://stackoverflow.com/questions/55051920/pandas-styler-object-has-no-attribute-hide-index-error - self.summary_df.style.set_properties(subset=[cols],**{'width': '500px'},**{'text-align': 'left'}).hide() - # print(self.summary) - - - def update_db(self,syringe_name='syringe_20_mL'): - sources = self.db_df['shipped_data'].source_id.unique() - centres = self.db_df['shipped_data'].site_id.unique() - - - for s in sources: - for c in centres: - #find the reference time of the shipped source - ref_act = self.db_df['shipped_data'][(self.db_df['shipped_data'].source_id == s) & (self.db_df['shipped_data'].site_id == c)]['A_ref_MBq'] - ref_time = self.db_df['shipped_data'][(self.db_df['shipped_data'].source_id == s) & (self.db_df['shipped_data'].site_id == c)]['ref_datetime'] - # print(self.db_df['cal_data'].loc[(self.db_df['cal_data'].source_id == s) & (self.db_df['shipped_data'].site_id == c)]) - - self.db_df['cal_data'].loc[((self.db_df['cal_data'].source_id == s) & (self.db_df['cal_data'].site_id == c)),'ref_act_MBq'] = ref_act.values[0] - self.db_df['cal_data'].loc[((self.db_df['cal_data'].source_id == s) & (self.db_df['cal_data'].site_id == c)),'ref_time'] = ref_time.values[0] - - - # calculate the delta time of the calibration of the shipped source and the measurement - self.db_df['cal_data']['delta_t'] = (self.db_df['cal_data']['measurement_datetime'] - self.db_df['cal_data']['ref_time']) / np.timedelta64(1,'D') - - self.db_df['cal_data']['decayed_ref'] = np.nan - - # decay correct reference sources to measurement time series - self.db_df['cal_data'].decayed_ref = self.db_df['cal_data'].apply(lambda row: decay_act(row.ref_act_MBq,row.delta_t,self.isotope_dic['half_life']),axis=1) - - # # Check that decay has been applied correctly. calculate perc_diff - self.db_df['cal_data']['decay_perc_diff'] = perc_diff(self.db_df['cal_data']['Ae_MBq'],self.db_df['cal_data'].decayed_ref) - - # #check recovery with the calculated decayed activity - self.db_df['cal_data']['recovery_calculated'] = (self.db_df['cal_data'].Am_MBq / self.db_df['cal_data']['decayed_ref'] *100).round(1) - - - #check the syringe - self.db_df['cal_data'].loc[self.db_df['cal_data'].source_id == syringe_name,'syringe_activity_calculated'] = self.db_df['cal_data']['Ai_syr_MBq'] - self.db_df['cal_data']['Af_syr_MBq'] - self.db_df['cal_data'].loc[self.db_df['cal_data'].source_id == syringe_name,'recovery_calculated'] = (self.db_df['cal_data']['Am_syr_MBq'] / self.db_df['cal_data']['syringe_activity_calculated'] * 100).round(1) - - # reorder columns - cols = ['site_id', 'department', 'city', 'performed_by', 'cal_type','manufacturer', 'model', 'collimator', 'initial_ cal_num', 'source_id','measurement_datetime', 'time_zone', 'ref_time','ref_act_MBq', 'delta_t','decayed_ref', 'Ae_MBq', 'decay_perc_diff', 'Am_MBq', 'Ai_syr_MBq','Af_syr_MBq', 'As_syr_MBq','syringe_activity_calculated', 'Am_syr_MBq', 'reported_recovery','recovery_calculated','final_cal_num', 'comments'] - - self.db_df['cal_data'] = self.db_df['cal_data'][cols] \ No newline at end of file diff --git a/doodle/qc/spect_qc.py b/doodle/qc/spect_qc.py deleted file mode 100644 index ca3992f..0000000 --- a/doodle/qc/spect_qc.py +++ /dev/null @@ -1,139 +0,0 @@ -from doodle.qc.qc import QC -import pydicom -import numpy as np -import pandas as pd - -class SPECTQC(QC): - - def __init__(self,isotope,projections_file,recon_file,db_dic,cal_type='spect'): - super().__init__(isotope,db_dic=db_dic,cal_type=cal_type) - self.proj_ds = pydicom.dcmread(projections_file) - self.recon_ds = pydicom.dcmread(recon_file) - - - def check_projs(self): - - self.window_check_df = {} - - self.append_to_summary(f'QC for SPECT RAW DATA of {self.isotope}:\n\n') - - # check acquisition parameters (i.e. projections) - self.check_camera_parameters(self.proj_ds,projs=True) - - # check image parameters (i.e.reconsturcte) - self.check_camera_parameters(self.recon_ds,projs=False) - - - self.print_summary() - - - - def check_camera_parameters(self,ds,projs=True): - camera_manufacturer = ds.Manufacturer - camera_model = ds.ManufacturerModelName - acquisition_date = ds.AcquisitionDate - acquisition_time = ds.AcquisitionTime - modality = ds.Modality - try: - duration =(ds.RotationInformationSequence[0].ActualFrameDuration / 1000) - except: - duration = np.nan - rows = ds.Rows - cols = ds.Columns - try: - zoom = ds.DetectorInformationSequence[0].ZoomFactor - except: - zoom = None - try: - number_projections = ds.RotationInformationSequence[0].NumberOfFramesInRotation * ds.NumberOfDetectors - except: - number_projections = np.nan - - - - if projs: - self.append_to_summary(f'CAMERA: {camera_manufacturer} {camera_model}\t \n') - self.append_to_summary(f'MODALITY: {modality}\t \n') - self.append_to_summary(f'Scan performed on: {acquisition_date} at {acquisition_time}\t \n\n') - self.append_to_summary(f' \t \n\n') - - self.append_to_summary(f'PROJECTIONS:\t \n') - - #check number of projections - if number_projections == self.isotope_dic['raw']['n_proj'][camera_manufacturer.lower().split(' ')[0]]: - self.append_to_summary(f"NUMBER OF PROJECTIONS: {number_projections} of {self.isotope_dic['raw']['n_proj'][camera_manufacturer.lower().split(' ')[0]]}\t OK\n\n") - else: - self.append_to_summary(f"NUMBER OF PROJECTIONS: {number_projections} of {self.isotope_dic['raw']['n_proj'][camera_manufacturer.lower().split(' ')[0]]}\t VERIFY\n\n") - - #check collimator - accepted_collimators = self.isotope_dic['accepted_collimators'] - if (len(self.db_df['cal_data']['collimator'].unique()) == 1) & (self.db_df['cal_data']['collimator'].unique()[0] in accepted_collimators): - self.append_to_summary(f"COLLIMATOR: {self.db_df['cal_data']['collimator'].unique()[0]}\t OK\n\n") - else: - self.append_to_summary(f"COLLIMATOR: {self.db_df['cal_data']['collimator'].unique()[0]} is not in the accepted collimator list.\t VERIFY\n\n") - - #check duration - if round(duration) == self.isotope_dic['raw']['duration_per_proj']: - self.append_to_summary(f'DURATION PER PROJECTION: {round(duration)} seconds\t OK\n\n') - else: - self.append_to_summary(f'DURATION PER PROJECTION: {duration} seconds (should be {self.isotope_dic["planar"]["duration"]} seconds)\t MISMATCH \n\n') - - #check energy windows - self.window_check_df['projections'] = self.window_check(type='raw') - - #check non-circular orbit - try: - if len(set(self.proj_ds.DetectorInformationSequence[0].RadialPosition)) > 1: - self.append_to_summary(f'ORBIT: Non-circular\t OK\n\n') - except: - self.append_to_summary(f'ORBIT: Circular\t VERIFY\n\n') - - - #check zoom - if zoom == self.isotope_dic['spect']['zoom']: - self.append_to_summary(f'ZOOM: {zoom}\t OK\n\n') - else: - self.append_to_summary(f'ZOOM: {zoom} (should be {self.isotope_dic["planar"]["zoom"]}\t MISMATCH\n\n') - - #check matrix size - if [rows,cols] == self.isotope_dic['raw']['matrix']: - self.append_to_summary(f'MATRIX SIZE: {rows} x {cols}\t OK\n\n') - else: - self.append_to_summary(f'MATRIX SIZE: {rows} x {cols} (should be {self.isotope_dic["spect"]["matrix"][0]} x {self.isotope_dic["spect"]["matrix"][1]}\tMISMATCH\n\n') - - #check detector motion - if ds.TypeOfDetectorMotion == self.isotope_dic['raw']['detector_motion']: - self.append_to_summary(f"DETECTOR MOTION: {self.isotope_dic['raw']['detector_motion']}\t OK\n\n") - else: - self.append_to_summary(f"DETECTOR MOTION: {ds.TypeOfDetectorMotion} (should be {self.isotope_dic['raw']['detector_motion']})\t MISMATCH \n\n") - - else: - self.append_to_summary(f' \t \n\n') - self.append_to_summary(f'\nRECONSTRUCTED IMAGE:\t \n') - - #check energy windows - self.window_check_df['reconstructed_image'] = self.window_check(type='spect') - - #check applied corrections - try: - if set(self.isotope_dic['spect']['corrections']).issubset(set(list(ds.CorrectedImage.split())).intersection(set(self.isotope_dic['spect']['corrections']))) : - self.append_to_summary(f"CORRECTIONS APPLIED: {self.isotope_dic['spect']['corrections']}\t OK\n\n") - else: - self.append_to_summary(f"CORRECTIONS APPLIED: {ds.CorrectedImage}. This image is not quantitative. Is missing {set(self.isotope_dic['spect']['corrections']) - set(list(ds.CorrectedImage))} correction.\t FAIL\n\n") - except: - if set(self.isotope_dic['spect']['corrections']).issubset(set(list(ds.CorrectedImage)).intersection(set(self.isotope_dic['spect']['corrections']))) : - self.append_to_summary(f"CORRECTIONS APPLIED: {self.isotope_dic['spect']['corrections']}\t OK\n\n") - else: - self.append_to_summary(f"CORRECTIONS APPLIED: {ds.CorrectedImage}. This image is not quantitative. Is missing {set(self.isotope_dic['spect']['corrections']) - set(list(ds.CorrectedImage))} correction.\t FAIL\n\n") - - #check matrix size - if [rows,cols] == self.isotope_dic['spect']['matrix']: - self.append_to_summary(f'MATRIX SIZE: {rows} x {cols}\t OK\n\n') - else: - self.append_to_summary(f'MATRIX SIZE: {rows} x {cols} (should be {self.isotope_dic["spect"]["matrix"][0]} x {self.isotope_dic["spect"]["matrix"][1]}\tMISMATCH\n\n') - - #check zoom - if zoom == self.isotope_dic['spect']['zoom']: - self.append_to_summary(f'ZOOM: {zoom}\t OK\n\n') - else: - self.append_to_summary(f'ZOOM: {zoom} (should be {self.isotope_dic["planar"]["zoom"]}\tMISMATCH\n\n') diff --git a/doodle/segmentation/__init__.py b/doodle/segmentation/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/doodle/shared/__init__.py b/doodle/shared/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/doodle/shared/corrections.py b/doodle/shared/corrections.py deleted file mode 100644 index 821a2ef..0000000 --- a/doodle/shared/corrections.py +++ /dev/null @@ -1,19 +0,0 @@ - - -def tew_scatt(window_dic): - - Cp = {} - - ls_width = window_dic['low_scatter']['width'] - us_width = window_dic['upper_scatter']['width'] - pp_width = window_dic['photopeak']['width'] - - for det in window_dic['photopeak']['counts']: - - Cs = (window_dic['low_scatter']['counts'][det] / ls_width + window_dic['upper_scatter']['counts'][det] / us_width) * pp_width / 2 - Cpw = window_dic['photopeak']['counts'][det] - - Cp[det] = Cpw - Cs - - # return the primary counts after scatter correction - return Cp \ No newline at end of file diff --git a/doodle/shared/evaluation_metrics.py b/doodle/shared/evaluation_metrics.py deleted file mode 100644 index ec0cf49..0000000 --- a/doodle/shared/evaluation_metrics.py +++ /dev/null @@ -1,5 +0,0 @@ -import numpy as np - -def perc_diff(measured_value,expected_value,decimals=2): - - return np.round((measured_value - expected_value) / expected_value *100,2) \ No newline at end of file diff --git a/doodle/shared/radioactive_decay.py b/doodle/shared/radioactive_decay.py deleted file mode 100644 index c998276..0000000 --- a/doodle/shared/radioactive_decay.py +++ /dev/null @@ -1,28 +0,0 @@ -import numpy as np -from datetime import datetime - -def decay_act(a_initial,delta_t,half_life): - - return a_initial * np.exp(-np.log(2)/half_life * delta_t) - - - - -def get_activity_at_injection(injection_date,pre_inj_activity,pre_inj_time,post_inj_activity,post_inj_time,injection_time,half_life): - - # Pass half-life in seconds - - # Set the times and the time deltas to injection time - pre_datetime = datetime.strptime(injection_date + pre_inj_time + '00.00','%Y%m%d%H%M%S.%f') - post_datetime = datetime.strptime(injection_date + post_inj_time + '00.00','%Y%m%d%H%M%S.%f') - inj_datetime = datetime.strptime(injection_date + injection_time + '00.00','%Y%m%d%H%M%S.%f') - - delta_inj_pre = (inj_datetime - pre_datetime).total_seconds() - delta_post_inj = (inj_datetime - post_datetime).total_seconds() - - pre_activity = decay_act(pre_inj_activity,delta_inj_pre,half_life) - post_activity = decay_act(post_inj_activity,delta_post_inj,half_life) - - injected_activity = pre_activity - post_activity - - return inj_datetime, injected_activity \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5cbc512 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,171 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "pytheranostics" +version = "0.2.0" +description = "A library of tools to process nuclear medicine scans and take them through the dosimetry workflow to calculate the absorbed dose in target organs." +readme = "README.md" +requires-python = ">=3.8" +license = "MIT" +authors = [ + { name = "Carlos Uribe, PhD, MCCPM", email = "curibe@bccrc.ca" }, + { name = "Pedro Esquinas, PhD" }, + { name = "Sara Kurkowska, MD, PhD" } +] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +# NOTE: When updating dependencies, also update .pre-commit-config.yaml pytest hook +dependencies = [ + "numpy", + "matplotlib", + "pandas", + "pydicom", + "pynetdicom", + "openpyxl", + "rt-utils", + "scikit-image", + "simpleitk", + "lmfit" +] + +[project.urls] +"Homepage" = "https://github.com/qurit/PyTheranostics" +"Bug Tracker" = "https://github.com/qurit/PyTheranostics/issues" + +[project.optional-dependencies] +test = [ + "pytest>=7.0", + "pytest-cov>=4.0", +] +docs = [ + "sphinx>=7.0", + "sphinx-rtd-theme>=1.0", + "sphinx-autodoc-typehints>=2.0", + "nbsphinx>=0.9", + "nbconvert>=7.0", + "myst-parser>=2.0", + "sphinx-copybutton>=0.5", + "sphinx-contributors>=0.1", + "pandoc>=2.4", + "ipython>=8.0", +] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "flake8>=6.0", + "black>=23.0", + "mypy>=1.0", + "pandas-stubs>=2.0.0", + "scipy-stubs", + "types-requests", + "pre-commit>=3.0.0", + "sphinx>=7.0", + "sphinx-rtd-theme>=1.0", + "sphinx-autodoc-typehints>=2.0", + "nbsphinx>=0.9", + "nbconvert>=7.0", + "myst-parser>=2.0", + "sphinx-copybutton>=0.5", + "sphinx-contributors>=0.1", + "pandoc>=2.4", + "ipython>=8.0", + "pydocstyle>=6.0", + "flake8-docstrings>=1.7" +] + +[tool.hatch.build] +include = ["pytheranostics/data/**/*"] + +[tool.hatch.build.targets.wheel] +packages = [ + "pytheranostics", + "pytheranostics.calibrations", + "pytheranostics.dicomtools", + "pytheranostics.dosimetry", + "pytheranostics.data", + "pytheranostics.fits", + "pytheranostics.plots", + "pytheranostics.qc", + "pytheranostics.segmentation", + "pytheranostics.registration", + "pytheranostics.shared" +] + +[tool.black] +line-length = 88 +target-version = ["py38", "py39", "py310", "py311", "py312"] +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.mypy_cache + | \.pytest_cache + | \.tox + | \.venv + | venv + | _build + | build + | dist + | data + | \.json$ + | \.yaml$ + | \.yml$ +)/ +''' + +# Note: Flake8 configuration is in setup.cfg (not here) +# Flake8 does not support pyproject.toml natively as of 2025 +# See: https://github.com/PyCQA/flake8/issues/234 + +[tool.mypy] +# Very strict mypy configuration for high-quality type checking +python_version = "3.8" +follow_imports = "skip" # Avoid optype internal error in dependencies +strict = true +warn_return_any = true +warn_unused_configs = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_unreachable = true +disallow_any_generics = true +disallow_subclassing_any = true +# Display settings +show_error_codes = true +pretty = true +error_summary = true +# Exclude data and build directories +exclude = [ + "pytheranostics/data/", + "build/", + "dist/", + ".venv/", + ".mypy_cache/", +] + +[tool.isort] +profile = "black" + +[tool.pytest.ini_options] +markers = [ + "smoke: marks tests as smoke tests (quick functionality checks)", + "slow: marks tests as slow (heavy computation or large data)", +] +filterwarnings = [ + "ignore:.*SwigPy.*:DeprecationWarning", + "ignore:.*swigvar.*:DeprecationWarning", +] +# Run smoke tests first and stop on first failure +addopts = "--tb=short -x" +# Test collection ordering: smoke tests first +testpaths = ["tests"] + +[tool.pydocstyle] +convention = "numpy" +add-ignore = ["D100", "D104"] # D100: Missing docstring in public module, D104: Missing docstring in public package +match = "(?!test_).*\\.py" # Ignore test files +match-dir = "^(?!(\\.git|\\.mypy_cache|\\.pytest_cache|build|dist|data)).*" diff --git a/pytheranostics/AUTHORS.rst b/pytheranostics/AUTHORS.rst new file mode 100644 index 0000000..4101192 --- /dev/null +++ b/pytheranostics/AUTHORS.rst @@ -0,0 +1,7 @@ + +Authors +======= + +* Carlos Uribe, PhD, MCCPM +* Pedro Esquinas, PhD +* Sara Kurkowska, MD, PhD diff --git a/pytheranostics/__init__.py b/pytheranostics/__init__.py new file mode 100644 index 0000000..878bd5d --- /dev/null +++ b/pytheranostics/__init__.py @@ -0,0 +1,89 @@ +"""PyTheranostics - A Python library for nuclear medicine processing and dosimetry.""" + +# Lazy access to subpackages (to support attribute access like `pytheranostics.imaging_ds`) +# without importing them eagerly or triggering unused-import lint issues. +import importlib + +# Import submodules for easier access +from pytheranostics.calibrations.gamma_camera import GammaCamera # Calibration +from pytheranostics.dicomtools.dicomtools import DicomModify # DICOM handling +from pytheranostics.fits.fits import biexp_fun, monoexp_fun, triexp_fun # Analysis +from pytheranostics.plots.plots import ewin_montage, plot_tac_residuals # Visualization +from pytheranostics.qc.dosecal_qc import DosecalQC # Core +from pytheranostics.qc.planar_qc import PlanarQC # Core +from pytheranostics.qc.spect_qc import SPECTQC # Core +from pytheranostics.segmentation.tools import rtst_to_mask # Image processing +from pytheranostics.shared.corrections import tew_scatt +from pytheranostics.shared.evaluation_metrics import perc_diff +from pytheranostics.shared.radioactive_decay import decay_act, get_activity_at_injection + +_SUBPACKAGES = { + "imaging_ds": "pytheranostics.imaging_ds", + "imaging_tools": "pytheranostics.imaging_tools", + "dosimetry": "pytheranostics.dosimetry", + "misc_tools": "pytheranostics.misc_tools", + "registration": "pytheranostics.registration", + "segmentation": "pytheranostics.segmentation", + "shared": "pytheranostics.shared", + "plots": "pytheranostics.plots", + "qc": "pytheranostics.qc", + "dicomtools": "pytheranostics.dicomtools", + "fits": "pytheranostics.fits", + "calibrations": "pytheranostics.calibrations", +} + + +def __getattr__(name): + """Dynamically expose subpackages and legacy aliases on first access. + + This allows patterns like `import pytheranostics as tx; tx.imaging_ds` to work + without importing all subpackages at import time. Also provides backwards- + compatible aliases used in older notebooks (e.g., `tx.MiscTools`). + """ + # Direct subpackages + if name in _SUBPACKAGES: + return importlib.import_module(_SUBPACKAGES[name]) + + # Legacy aliases (backwards compatibility) + if name == "MiscTools": # alias to misc_tools + return importlib.import_module("pytheranostics.misc_tools") + if name == "ImagingTools": # alias to imaging_tools + return importlib.import_module("pytheranostics.imaging_tools") + + raise AttributeError(f"module '{__name__}' has no attribute '{name}'") + + +# Define what should be imported with "from pytheranostics import *" +__all__ = [ + "PlanarQC", + "DosecalQC", + "SPECTQC", + "decay_act", + "get_activity_at_injection", + "perc_diff", + "tew_scatt", + "GammaCamera", + "ewin_montage", + "plot_tac_residuals", + "rtst_to_mask", + "monoexp_fun", + "biexp_fun", + "triexp_fun", + "DicomModify", + # Expose subpackage names at the package level for discoverability + "imaging_ds", + "imaging_tools", + "dosimetry", + "misc_tools", + "registration", + "segmentation", + "shared", + "plots", + "qc", + "dicomtools", + "fits", + "calibrations", + # Legacy aliases + "MiscTools", + "ImagingTools", +] diff --git a/pytheranostics/calibrations/__init__.py b/pytheranostics/calibrations/__init__.py new file mode 100644 index 0000000..3731650 --- /dev/null +++ b/pytheranostics/calibrations/__init__.py @@ -0,0 +1 @@ +"""PyTheranostics package.""" diff --git a/pytheranostics/calibrations/gamma_camera.py b/pytheranostics/calibrations/gamma_camera.py new file mode 100644 index 0000000..39013b3 --- /dev/null +++ b/pytheranostics/calibrations/gamma_camera.py @@ -0,0 +1,250 @@ +"""Gamma camera calibration utilities.""" + +import json +import math +import pprint +from datetime import datetime +from pathlib import Path + +# import pydicom +import numpy as np + +from pytheranostics.plots.plots import ewin_montage +from pytheranostics.qc.planar_qc import PlanarQC +from pytheranostics.shared.corrections import tew_scatt +from pytheranostics.shared.radioactive_decay import decay_act + +this_dir = Path(__file__).resolve().parent.parent +CALIBRATIONS_DATA_FILE = Path(this_dir, "data", "gamma_camera_sensitivities.json") + + +class GammaCamera(PlanarQC): + """Encapsulate gamma camera QC and sensitivity calculations.""" + + def __init__(self, isotope, dicomfile, db_dic, cal_type="planar"): + """Initialize the planar QC base class and load site metadata.""" + super().__init__(isotope, dicomfile, db_dic=db_dic, cal_type=cal_type) + + def get_sensitivity(self, source_id="C", **kwargs): + """Calculate camera sensitivity for the provided calibration source.""" + # ser_date = self.ds.SeriesDate + # ser_time = self.ds.SeriesTime + + # pix_space = self.ds.PixelSpacing # TODO: Use this variable or remove (flake8) + + # duration of scan in seconds + acq_duration = self.ds.ActualFrameDuration / 1000 + + # ndet = self.ds.NumberOfDetectors # TODO: Use this variable or remove (flake8) + + # find camera model + if "site_id" in kwargs: + if kwargs["site_id"] == "CAVA": + if hasattr(self.ds, "DeviceSerialNumber"): + camera_model = "Intevo" + else: + camera_model = "Symbia T" + elif kwargs["site_id"] == "CAHJ": + if self.ds.ManufacturerModelName == "Tandem_870_DR": + camera_model = "Discovery 870" + elif kwargs["site_id"] == "CAGQ": + if self.ds.ManufacturerModelName == "Encore2": + camera_model = "Intevo T6" + elif kwargs["site_id"] == "CAGA": + if self.ds.ManufacturerModelName == "Encore2": + camera_model = "Intevo T6" + elif kwargs["site_id"] == "CAHN": + if self.ds.ManufacturerModelName == "Tandem_Discovery_670_ES": + camera_model = "Discovery 670" + elif kwargs["site_id"] == "CAGH": + if self.ds.ManufacturerModelName == "Tandem_Discovery_670": + camera_model = "Discovery 670" + elif kwargs["site_id"] == "CANL": + if self.ds.ManufacturerModelName == "Tandem_Discovery_670_Pro": + camera_model = "Discovery 670" + elif kwargs["site_id"] == "CAMN": + if self.ds.ManufacturerModelName == "Tandem_Optima_640": + camera_model = "Optima 640" + elif kwargs["site_id"] == "CAMP": + if self.ds.ManufacturerModelName == "Encore2": + camera_model = "Symbia" + elif kwargs["site_id"] == "CATW": + if self.ds.ManufacturerModelName == "Encore2": + camera_model = "Symbia T-16" + elif kwargs["site_id"] == "CATC": + if self.ds.ManufacturerModelName == "Tandem_Discovery_670": + camera_model = "Discovery 670" + + # find activity of source + if "site_id" in kwargs: + df = self.db_df["cal_data"] + + df2 = df[ + (df.site_id == kwargs["site_id"]) + & (df.source_id == source_id) + & (df.cal_type == "planar") + & (df.model == camera_model) + ] + A_ref = df2.ref_act_MBq.values[0] + ref_time = df2.ref_time.values[0] + + acq_time = self.ds.AcquisitionTime + acq_date = self.ds.AcquisitionDate + + if "." in acq_time: + acq_time = np.datetime64( + datetime.strptime(f"{acq_date} {acq_time}", "%Y%m%d %H%M%S.%f") + ) + else: + acq_time = np.datetime64( + datetime.strptime(f"{acq_date} {acq_time}", "%Y%m%d %H%M%S") + ) + + # find the time difference in days + if self.isotope_dic["half_life_units"] == "days": + delta_t = (acq_time - ref_time) / np.timedelta64(1, "D") + elif self.isotope_dic["half_life_units"] == "hours": + delta_t = (acq_time - ref_time) / np.timedelta64(1, "h") + else: + print("check units of decay correction") + + # Decay the reference activity + self.A_decayed = decay_act(A_ref, delta_t, self.isotope_dic["half_life"]) + print( + f"The activity of the source at the time of the scan was {self.A_decayed} MBq\n" + ) + + # deal with energy windows + nwin = self.ds.NumberOfEnergyWindows + + ewin = {} + + img = self.ds.pixel_array + + for w in range(nwin): + lower = ( + self.ds.EnergyWindowInformationSequence[w] + .EnergyWindowRangeSequence[0] + .EnergyWindowLowerLimit + ) + upper = ( + self.ds.EnergyWindowInformationSequence[w] + .EnergyWindowRangeSequence[0] + .EnergyWindowUpperLimit + ) + center = round((upper + lower) / 2, 2) + cent = str(int(center)) + + ewin[cent] = {} + + ewin[cent]["lower"] = lower + ewin[cent]["upper"] = upper + ewin[cent]["center"] = center + ewin[cent]["width"] = upper - lower + ewin[cent]["counts"] = {} + + # get counts in each window and detector + for ind, i in enumerate(range(0, int(img.shape[0]), 2)): + keys = list(ewin.keys()) + + ewin[keys[ind]]["counts"]["Detector1"] = np.sum(img[i, :, :]) + ewin[keys[ind]]["counts"]["Detector2"] = np.sum(img[i + 1, :, :]) + + self.win_check = {} + + # TODO: Improve this part + for el in ewin: + for k, w in self.isotope_dic["windows_kev"].items(): + if int(ewin[el]["center"]) in range(round(w[0]), round(w[2])): + self.win_check[k] = ewin[el] + + ewin_montage(img, ewin) + + print("Info about the different energy windows and detectors:") + pprint.pprint(self.win_check) + print("\n") + + self.Cp = tew_scatt(self.win_check) + + print("Primary counts in each detector") + pprint.pprint(self.Cp) + + # print(self.A_decayed,delta_t,ref_time,acq_time) + # Calculate sensitivity in cps/MBq + sensitivity = { + k: v / (self.A_decayed * acq_duration) for k, v in self.Cp.items() + } + sensitivity["Average"] = sum(sensitivity.values()) / len(sensitivity) + + # calculate calibration factor in units of MBq/cps + calibration_factor = {k: 1 / v for k, v in sensitivity.items()} + + self.cal_dic = {} + self.cal_dic[kwargs["site_id"]] = {} + self.cal_dic[kwargs["site_id"]][camera_model] = {} + self.cal_dic[kwargs["site_id"]][camera_model]["manufacturer"] = ( + df2.manufacturer.values[0] + ) + self.cal_dic[kwargs["site_id"]][camera_model]["collimator"] = ( + df2.collimator.values[0] + ) + self.cal_dic[kwargs["site_id"]][camera_model]["sensitivity"] = sensitivity + self.cal_dic[kwargs["site_id"]][camera_model][ + "calibration_factor" + ] = calibration_factor + + print("\n") + print( + "Calibration Results. Sensitivity in cps/MBq. Calibration factor in MBq/cps" + ) + pprint.pprint(self.cal_dic) + + def calculate_uncertainty(self, site_id, camera_model, uncertainty_activity): + """Compute calibration factor and sensitivity uncertainties.""" + u_prim_list = [] + for detector in ["Detector1", "Detector2"]: + u_pw = math.sqrt(self.win_check["photopeak"]["counts"][detector]) + u_sc = math.sqrt( + self.win_check["low_scatter"]["counts"][detector] + + self.win_check["upper_scatter"]["counts"][detector] + ) + u_prim = math.sqrt(u_pw**2 + u_sc**2) + u_prim_list.append(u_prim) + + final_u_counts = 1 / 2 * math.sqrt( + u_prim_list[0] ** 2 + u_prim_list[1] ** 2 + ) + np.std(u_prim_list) + counts = (self.Cp["Detector1"] + self.Cp["Detector2"]) / 2 + u_activity = self.A_decayed * uncertainty_activity + u_time = 0.001 + + uncertainty_cf = self.cal_dic[site_id][camera_model]["calibration_factor"][ + "Average" + ] * math.sqrt( + (final_u_counts / counts) ** 2 + + (u_activity / self.A_decayed) ** 2 + + (u_time / self.ds.ActualFrameDuration / 1000) ** 2 + ) + uncertainty_sensitivity = self.cal_dic[site_id][camera_model]["sensitivity"][ + "Average" + ] * math.sqrt( + (final_u_counts / counts) ** 2 + + (u_activity / self.A_decayed) ** 2 + + (u_time / self.ds.ActualFrameDuration / 1000) ** 2 + ) + + return uncertainty_cf, uncertainty_sensitivity + + def calfactor_to_database(self, **kwargs): + """Persist calibration factors to the shared JSON database.""" + if "site_id" in kwargs: + site_id = kwargs["site_id"] + + with open(CALIBRATIONS_DATA_FILE, "r+") as f: + self.calfactors_dic = json.load(f) + f.seek(0) + if site_id in self.calfactors_dic.keys(): + self.calfactors_dic[site_id].update(self.cal_dic[site_id]) + else: + self.calfactors_dic.update(self.cal_dic) + json.dump(self.calfactors_dic, f, indent=2) diff --git a/pytheranostics/data/.prettierrc b/pytheranostics/data/.prettierrc new file mode 100644 index 0000000..222861c --- /dev/null +++ b/pytheranostics/data/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 2, + "useTabs": false +} diff --git a/pytheranostics/data/Params.yaml b/pytheranostics/data/Params.yaml new file mode 100644 index 0000000..3324a61 --- /dev/null +++ b/pytheranostics/data/Params.yaml @@ -0,0 +1,64 @@ +# This is an example of a parameters file +# It is written according to the YAML-convention (www.yaml.org) and is checked by the code for consistency. +# Three types of parameters are possible and reflected in the structure of the document: +# +# Parameter category: +# Setting Name: +# +# The three parameter categories are: +# - setting: Setting to use for preprocessing and class specific settings. if no is specified, the value for +# this setting is set to None. +# - featureClass: Feature class to enable, is list of strings representing enabled features. If no is +# specified or is an empty list ('[]'), all features for this class are enabled. +# - imageType: image types to calculate features on. is custom kwarg settings (dictionary). if is an +# empty dictionary ('{}'), no custom settings are added for this input image. +# +# Some parameters have a limited list of possible values. Where this is the case, possible values are listed in the +# package documentation + +# Settings to use, possible settings are listed in the documentation (section "Customizing the extraction"). +setting: + binWidth: 1 + label: 1 + interpolator: 'sitkBSpline' # This is an enumerated value, here None is not allowed + resampledPixelSpacing: # This disables resampling, as it is interpreted as None, to enable it, specify spacing in x, y, z as [x, y , z] + weightingNorm: # If no value is specified, it is interpreted as None + minimumROIDimensions: 1 + +# Image types to use: "Original" for unfiltered image, for possible filters, see documentation. +imageType: + Original: {} # for dictionaries / mappings, None values are not allowed, '{}' is interpreted as an empty dictionary + +# Featureclasses, from which features must be calculated. If a featureclass is not mentioned, no features are calculated +# for that class. Otherwise, the specified features are calculated, or, if none are specified, all are calculated (excluding redundant/deprecated features). +featureClass: + # redundant Compactness 1, Compactness 2 an Spherical Disproportion features are disabled by default, they can be + # enabled by specifying individual feature names (as is done for glcm) and including them in the list. + shape: + firstorder: [] # specifying an empty list has the same effect as specifying nothing. + glcm: # Disable SumAverage by specifying all other GLCM features available +# - 'Autocorrelation' +# - 'JointAverage' +# - 'ClusterProminence' +# - 'ClusterShade' +# - 'ClusterTendency' +# - 'Contrast' +# - 'Correlation' +# - 'DifferenceAverage' +# - 'DifferenceEntropy' +# - 'DifferenceVariance' +# - 'JointEnergy' +# - 'JointEntropy' +# - 'Imc1' +# - 'Imc2' +# - 'Idm' +# - 'Idmn' +# - 'Id' +# - 'Idn' +# - 'InverseVariance' +# - 'MaximumProbability' +# - 'SumEntropy' +# - 'SumSquares' + glrlm: # for lists none values are allowed, in this case, all features are enabled + glszm: + gldm: # contains deprecated features, but as no individual features are specified, the deprecated features are not enabled diff --git a/pytheranostics/data/README.md b/pytheranostics/data/README.md new file mode 100644 index 0000000..83f1f19 --- /dev/null +++ b/pytheranostics/data/README.md @@ -0,0 +1,23 @@ +# PyTheranostics Data Layout + +All reference assets that ship with the library live under this directory so +they are available to both library code and tests via `importlib.resources`. + +``` +pytheranostics/data/ +├── phantom/ +│ ├── human/ # ICRP organ masses, literature tables, supporting workbooks +│ └── mouse/ # Preclinical phantom masses, scaling factors, literature data +├── olinda/ +│ └── templates/ +│ ├── human/ # Adult male/female OLINDA case templates +│ └── mouse/ # Mouse-specific case templates (e.g., mouse25g) +├── s-values/ +│ ├── organ/ # Radionuclide/sex-specific organ S-value tables +│ └── spheres.json +├── monte_carlo/ # Geant4/GATE templates used by voxel dosimetry +└── phantom/ # (additional imaging phantoms, e.g., skeleton masks) +``` + +When adding new assets, prefer extending these folders (or adding a clearly +named subdirectory) instead of nesting data inside individual subpackages. diff --git a/pytheranostics/data/__init__.py b/pytheranostics/data/__init__.py new file mode 100644 index 0000000..1e67676 --- /dev/null +++ b/pytheranostics/data/__init__.py @@ -0,0 +1 @@ +"""Package containing data assets distributed with PyTheranostics.""" diff --git a/pytheranostics/data/gamma_counter_calibrations.json b/pytheranostics/data/gamma_counter_calibrations.json new file mode 100644 index 0000000..0b41e73 --- /dev/null +++ b/pytheranostics/data/gamma_counter_calibrations.json @@ -0,0 +1,15 @@ +{ + "Hidex":{ + "Lu-177":{ + "cal_factor":0.000438, + "cal_factor_units":"kBq/cpm" + } + + }, + "Wizard":{ + "Lu-177":{ + "cal_factor":0.000446, + "cal_factor_units":"kBq/cpm" + } + } +} diff --git a/doodle/data/isotopes.json b/pytheranostics/data/isotopes.json similarity index 86% rename from doodle/data/isotopes.json rename to pytheranostics/data/isotopes.json index 3c78050..13f6de5 100644 --- a/doodle/data/isotopes.json +++ b/pytheranostics/data/isotopes.json @@ -1,13 +1,13 @@ { "Lu177":{ - "half_life":6.647, - "half_life_units":"days", + "half_life": 159.528, + "half_life_units":"hours", "nwin":4, "windows_kev":{ "photopeak":[187.2,208,228.8], "low_scatter":[166.4,176.8,187.2], "upper_scatter":[228.8,239.2,249.6], - "general_scatter":[55,110,165] + "general_scatter":[55,110,165] }, "accepted_collimators":["MELP","MEGP","MEGL"], "planar":{ @@ -27,4 +27,4 @@ "zoom":[1, 1] } } -} \ No newline at end of file +} diff --git a/pytheranostics/data/monte_carlo/data/GateMaterials.db b/pytheranostics/data/monte_carlo/data/GateMaterials.db new file mode 100644 index 0000000..599bc40 --- /dev/null +++ b/pytheranostics/data/monte_carlo/data/GateMaterials.db @@ -0,0 +1,687 @@ +[Elements] +Hydrogen: S= H ; Z= 1. ; A= 1.01 g/mole +Helium: S= He ; Z= 2. ; A= 4.003 g/mole +Lithium: S= Li ; Z= 3. ; A= 6.941 g/mole +Beryllium: S= Be ; Z= 4. ; A= 9.012 g/mole +Boron: S= B ; Z= 5. ; A= 10.811 g/mole +Carbon: S= C ; Z= 6. ; A= 12.01 g/mole +Nitrogen: S= N ; Z= 7. ; A= 14.01 g/mole +Oxygen: S= O ; Z= 8. ; A= 16.00 g/mole +Fluorine: S= F ; Z= 9. ; A= 18.998 g/mole +Neon: S= Ne ; Z= 10. ; A= 20.180 g/mole +Sodium: S= Na ; Z= 11. ; A= 22.99 g/mole +Magnesium: S= Mg ; Z= 12. ; A= 24.305 g/mole +Aluminium: S= Al ; Z= 13. ; A= 26.98 g/mole +Silicon: S= Si ; Z= 14. ; A= 28.09 g/mole +Phosphor: S= P ; Z= 15. ; A= 30.97 g/mole +Sulfur: S= S ; Z= 16. ; A= 32.066 g/mole +Chlorine: S= Cl ; Z= 17. ; A= 35.45 g/mole +Argon: S= Ar ; Z= 18. ; A= 39.95 g/mole +Potassium: S= K ; Z= 19. ; A= 39.098 g/mole +Calcium: S= Ca ; Z= 20. ; A= 40.08 g/mole +Scandium: S= Sc ; Z= 21. ; A= 44.956 g/mole +Titanium: S= Ti ; Z= 22. ; A= 47.867 g/mole +Vandium: S= V ; Z= 23. ; A= 50.942 g/mole +Chromium: S= Cr ; Z= 24. ; A= 51.996 g/mole +Manganese: S= Mn ; Z= 25. ; A= 54.938 g/mole +Iron: S= Fe ; Z= 26. ; A= 55.845 g/mole +Cobalt: S= Co ; Z= 27. ; A= 58.933 g/mole +Nickel: S= Ni ; Z= 28. ; A= 58.693 g/mole +Copper: S= Cu ; Z= 29. ; A= 63.39 g/mole +Zinc: S= Zn ; Z= 30. ; A= 65.39 g/mole +Gallium: S= Ga ; Z= 31. ; A= 69.723 g/mole +Germanium: S= Ge ; Z= 32. ; A= 72.61 g/mole +Rubidium: S= Rb ; Z= 37. ; A= 85.468 g/mole +Yttrium: S= Y ; Z= 39. ; A= 88.91 g/mole +Zirconium: S= Zr ; Z= 40. ; A= 91.224 g/mole +Cadmium: S= Cd ; Z= 48. ; A= 112.41 g/mole +Tellurium: S= Te ; Z= 52. ; A= 127.6 g/mole +Iodine: S= I ; Z= 53. ; A= 126.90 g/mole +Cesium: S= Cs ; Z= 55. ; A= 132.905 g/mole +Gadolinium: S= Gd ; Z= 64. ; A= 157.25 g/mole +Lutetium: S= Lu ; Z= 71. ; A= 174.97 g/mole +Tungsten: S= W ; Z= 74. ; A= 183.84 g/mole +Thallium: S= Tl ; Z= 81. ; A= 204.37 g/mole +Lead: S= Pb ; Z= 82. ; A= 207.20 g/mole +Bismuth: S= Bi ; Z= 83. ; A= 208.98 g/mole +Uranium: S= U ; Z= 92. ; A= 238.03 g/mole +Silver: S= Ag ; Z= 47. ; A= 107.87 g/mole +Tin: S=Sn ; Z= 50. ; A= 118.71 g/mole + +[Materials] +Vacuum: d=0.000001 mg/cm3 ; n=1 + +el: name=Hydrogen ; n=1 + +Aluminium: d=2.7 g/cm3 ; n=1 ; state=solid + +el: name=auto ; n=1 + +Uranium: d=18.90 g/cm3 ; n=1 ; state=solid + +el: name=auto ; n=1 + +Silicon: d=2.33 g/cm3 ; n=1 ; state=solid + +el: name=auto ; n=1 + +Germanium: d=5.32 g/cm3 ; n=1 ; state=solid + +el: name=auto ; n=1 + +Yttrium: d=4.47 g/cm3 ; n=1 + +el: name=auto ; n=1 + +Gadolinium: d=7.9 g/cm3 ; n=1 + +el: name=auto ; n=1 + +Lutetium: d=9.84 g/cm3 ; n=1 + +el: name=auto ; n=1 + +Tungsten: d=19.3 g/cm3 ; n=1 ; state=solid + +el: name=auto ; n=1 + +Lead: d=11.4 g/cm3 ; n=1 ; state=solid + +el: name=auto ; n=1 + +Bismuth: d=9.75 g/cm3 ; n=1 ; state=solid + +el: name=auto ; n=1 + +Titanium: d=4.54 g/cm3; n=1; state=solid + +el: name=auto; n=1 + +Carbon: d=2.1 g/cm3; n=1; state=solid + +el: name=auto; n=1 + +NaI: d=3.67 g/cm3; n=2; state=solid + +el: name=Sodium ; n=1 + +el: name=Iodine ; n=1 + +PWO: d=8.28 g/cm3; n=3 ; state=Solid + +el: name=Lead; n=1 + +el: name=Tungsten; n=1 + +el: name=Oxygen; n=4 + +BGO: d=7.13 g/cm3; n= 3 ; state=solid + +el: name=Bismuth; n=4 + +el: name=Germanium; n=3 + +el: name=Oxygen; n=12 + +LSO: d=7.4 g/cm3; n=3 ; state=Solid + +el: name=Lutetium ; n=2 + +el: name=Silicon; n=1 + +el: name=Oxygen; n=5 + +Plexiglass: d=1.19 g/cm3; n=3; state=solid + +el: name=Hydrogen; f=0.080538 + +el: name=Carbon; f=0.599848 + +el: name=Oxygen; f=0.319614 + + +GSO: d=6.7 g/cm3; n=3 ; state=Solid + +el: name=Gadolinium ; n=2 + +el: name=Silicon; n=1 + +el: name=Oxygen; n=5 + +LuAP: d=8.34 g/cm3; n=3 ; state=Solid + +el: name=Lutetium ; n=1 + +el: name=Aluminium; n=1 + +el: name=Oxygen; n=3 + +YAP: d=5.55 g/cm3; n=3 ; state=Solid + +el: name=Yttrium ; n=1 + +el: name=Aluminium; n=1 + +el: name=Oxygen; n=3 + +Water: d=1.00 g/cm3; n=2 ; state=liquid + +el: name=Hydrogen ; n=2 + +el: name=Oxygen; n=1 + +Quartz: d=2.2 g/cm3; n=2 ; state=Solid + +el: name=Silicon ; n=1 + +el: name=Oxygen ; n=2 + +SoftTissueITALY: d=1.04 g/cm3 ; n=16 + +el: name=Hydrogen; f=0.10454 + +el: name=Carbon; f=0.22663 + +el: name=Nitrogen; f=0.02490 + +el: name=Oxygen; f=0.63525 + +el: name=Sodium; f=0.00112 + +el: name=Magnesium; f=0.00013 + +el: name=Silicon; f=0.00030 + +el: name=Phosphor; f=0.00134 + +el: name=Sulfur; f=0.00204 + +el: name=Chlorine; f=0.00133 + +el: name=Potassium; f=0.00208 + +el: name=Calcium; f=0.00024 + +el: name=Iron; f=0.00005 + +el: name=Zinc; f=0.00003 + +el: name=Rubidium; f=0.00001 + +el: name=Zirconium; f=0.00001 + + +SoftTissueICRP: d=1.00 g/cm3 ; n=13 + +el: name=Hydrogen; f=0.104472 + +el: name=Carbon; f=0.232190 + +el: name=Nitrogen; f=0.024880 + +el: name=Oxygen; f=0.630238 + +el: name=Sodium; f=0.001130 + +el: name=Magnesium; f=0.000130 + +el: name=Phosphor; f=0.001330 + +el: name=Sulfur; f=0.001990 + +el: name=Chlorine; f=0.001340 + +el: name=Potassium; f=0.001990 + +el: name=Calcium; f=0.000230 + +el: name=Iron; f=0.000050 + +el: name=Zinc; f=0.000030 + +LungICRP: d=1.05 g/cm3 ; n=13 + +el: name=Hydrogen; f=0.101278 + +el: name=Carbon; f=0.102310 + +el: name=Nitrogen; f=0.028650 + +el: name=Oxygen; f=0.757072 + +el: name=Sodium; f=0.001840 + +el: name=Magnesium; f=0.000730 + +el: name=Phosphor; f=0.000800 + +el: name=Sulfur; f=0.002250 + +el: name=Chlorine; f=0.002660 + +el: name=Potassium; f=0.001940 + +el: name=Calcium; f=0.000090 + +el: name=Iron; f=0.000370 + +el: name=Zinc; f=0.000010 + +CorticalBoneICRP: d=1.85 g/cm3 ; n=9 + +el: name=Hydrogen; f=0.047234 + +el: name=Carbon; f=0.144330 + +el: name=Nitrogen; f=0.041990 + +el: name=Oxygen; f=0.446096 + +el: name=Magnesium; f=0.002200 + +el: name=Phosphor; f=0.104970 + +el: name=Sulfur; f=0.003150 + +el: name=Calcium; f=0.209930 + +el: name=Zinc; f=0.000100 + +RibsICRP: d=1.165 g/cm3 ; n=12 + +el: name=Hydrogen; f=0.089 + +el: name=Carbon; f=0.292 + +el: name=Nitrogen; f=0.029 + +el: name=Oxygen; f=0.507 + +el: name=Sodium; f=0.002 + +el: name=Magnesium; f=0 + +el: name=Phosphor; f=0.026 + +el: name=Sulfur; f=0.004 + +el: name=Chlorine; f=0.002 + +el: name=Potassium; f=0.001 + +el: name=Calcium; f=0.048 + +el: name=Iron; f=0 + +HumeriUpperICRP: d=1.205 g/cm3 ; n=12 + +el: name=Hydrogen; f=0.085 + +el: name=Carbon; f=0.288 + +el: name=Nitrogen; f=0.026 + +el: name=Oxygen; f=0.498 + +el: name=Sodium; f=0.002 + +el: name=Magnesium; f=0.001 + +el: name=Phosphor; f=0.033 + +el: name=Sulfur; f=0.004 + +el: name=Chlorine; f=0.002 + +el: name=Potassium; f=0 + +el: name=Calcium; f=0.061 + +el: name=Iron; f=0 + +FemoraUpperICRP: d=1.124 g/cm3 ; n=12 + +el: name=Hydrogen; f=0.094 + +el: name=Carbon; f=0.385 + +el: name=Nitrogen; f=0.022 + +el: name=Oxygen; f=0.430 + +el: name=Sodium; f=0.002 + +el: name=Magnesium; f=0 + +el: name=Phosphor; f=0.022 + +el: name=Sulfur; f=0.003 + +el: name=Chlorine; f=0.001 + +el: name=Potassium; f=0 + +el: name=Calcium; f=0.041 + +el: name=Iron; f=0 + +SacrumICRP: d=1.031 g/cm3 ; n=12 + +el: name=Hydrogen; f=0.105 + +el: name=Carbon; f=0.419 + +el: name=Nitrogen; f=0.0270 + +el: name=Oxygen; f=0.432 + +el: name=Sodium; f=0.001 + +el: name=Magnesium; f=0 + +el: name=Phosphor; f=0.004 + +el: name=Sulfur; f=0.002 + +el: name=Chlorine; f=0.002 + +el: name=Potassium; f=0.001 + +el: name=Calcium; f=0.006 + +el: name=Iron; f=0.001 + +LumbarVertebraeICRP: d=1.112 g/cm3 ; n=12 + +el: name=Hydrogen; f=0.095 + +el: name=Carbon; f=0.340 + +el: name=Nitrogen; f=0.028 + +el: name=Oxygen; f=0.480 + +el: name=Sodium; f=0.001 + +el: name=Magnesium; f=0 + +el: name=Phosphor; f=0.018 + +el: name=Sulfur; f=0.003 + +el: name=Chlorine; f=0.002 + +el: name=Potassium; f=0.001 + +el: name=Calcium; f=0.032 + +el: name=Iron; f=0 + +ThoracicVertebraeICRP: d=1.074 g/cm3 ; n=12 + +el: name=Hydrogen; f=0.099 + +el: name=Carbon; f=0.376 + +el: name=Nitrogen; f=0.027 + +el: name=Oxygen; f=0.459 + +el: name=Sodium; f=0.001 + +el: name=Magnesium; f=0 + +el: name=Phosphor; f=0.012 + +el: name=Sulfur; f=0.002 + +el: name=Chlorine; f=0.002 + +el: name=Potassium; f=0.001 + +el: name=Calcium; f=0.020 + +el: name=Iron; f=0.001 + +CervicalVertebraeICRP: d=1.05 g/cm3 ; n=12 + +el: name=Hydrogen; f=0.103 + +el: name=Carbon; f=0.400 + +el: name=Nitrogen; f=0.027 + +el: name=Oxygen; f=0.444 + +el: name=Sodium; f=0.001 + +el: name=Magnesium; f=0 + +el: name=Phosphor; f=0.007 + +el: name=Sulfur; f=0.002 + +el: name=Chlorine; f=0.002 + +el: name=Potassium; f=0.001 + +el: name=Calcium; f=0.012 + +el: name=Iron; f=0.001 + +PelvisICRP: d=1.123 g/cm3 ; n=12 + +el: name=Hydrogen; f=0.094 + +el: name=Carbon; f=0.360 + +el: name=Nitrogen; f=0.025 + +el: name=Oxygen; f=0.454 + +el: name=Sodium; f=0.002 + +el: name=Magnesium; f=0 + +el: name=Phosphor; f=0.021 + +el: name=Sulfur; f=0.003 + +el: name=Chlorine; f=0.002 + +el: name=Potassium; f=0.001 + +el: name=Calcium; f=0.038 + +el: name=Iron; f=0 + +ClaviclesICRP: d=1.151 g/cm3 ; n=12 + +el: name=Hydrogen; f=0.091 + +el: name=Carbon; f=0.348 + +el: name=Nitrogen; f=0.024 + +el: name=Oxygen; f=0.457 + +el: name=Sodium; f=0.002 + +el: name=Magnesium; f=0 + +el: name=Phosphor; f=0.026 + +el: name=Sulfur; f=0.003 + +el: name=Chlorine; f=0.001 + +el: name=Potassium; f=0 + +el: name=Calcium; f=0.048 + +el: name=Iron; f=0 + +SternumICRP: d=1.041 g/cm3 ; n=12 + +el: name=Hydrogen; f=0.104 + +el: name=Carbon; f=0.409 + +el: name=Nitrogen; f=0.027 + +el: name=Oxygen; f=0.438 + +el: name=Sodium; f=0.001 + +el: name=Magnesium; f=0 + +el: name=Phosphor; f=0.006 + +el: name=Sulfur; f=0.002 + +el: name=Chlorine; f=0.002 + +el: name=Potassium; f=0.001 + +el: name=Calcium; f=0.009 + +el: name=Iron; f=0.001 + +ScapulaICRP: d=1.183 g/cm3 ; n=12 + +el: name=Hydrogen; f=0.087 + +el: name=Carbon; f=0.309 + +el: name=Nitrogen; f=0.026 + +el: name=Oxygen; f=0.483 + +el: name=Sodium; f=0.002 + +el: name=Magnesium; f=0.001 + +el: name=Phosphor; f=0.030 + +el: name=Sulfur; f=0.004 + +el: name=Chlorine; f=0.002 + +el: name=Potassium; f=0 + +el: name=Calcium; f=0.0556 + +el: name=Iron; f=0 + +MandibleICRP: d=1.228 g/cm3 ; n=12 + +el: name=Hydrogen; f=0.083 + +el: name=Carbon; f=0.266 + +el: name=Nitrogen; f=0.027 + +el: name=Oxygen; f=0.511 + +el: name=Sodium; f=0.003 + +el: name=Magnesium; f=0.001 + +el: name=Phosphor; f=0.036 + +el: name=Sulfur; f=0.004 + +el: name=Chlorine; f=0.002 + +el: name=Potassium; f=0 + +el: name=Calcium; f=0.067 + +el: name=Iron; f=0 + +CraniumICRP: d=1.157 g/cm3 ; n=12 + +el: name=Hydrogen; f=0.090 + +el: name=Carbon; f=0.335 + +el: name=Nitrogen; f=0.025 + +el: name=Oxygen; f=0.467 + +el: name=Sodium; f=0.002 + +el: name=Magnesium; f=0.0 + +el: name=Phosphor; f=0.026 + +el: name=Sulfur; f=0.003 + +el: name=Chlorine; f=0.002 + +el: name=Potassium; f=0.001 + +el: name=Calcium; f=0.049 + +el: name=Iron; f=0 + +Breast: d=1.020 g/cm3 ; n = 8 + +el: name=Oxygen; f=0.5270 + +el: name=Carbon; f=0.3320 + +el: name=Hydrogen ; f=0.1060 + +el: name=Nitrogen; f=0.0300 + +el: name=Sulfur ; f=0.0020 + +el: name=Sodium ; f=0.0010 + +el: name=Phosphor; f=0.0010 + +el: name=Chlorine ; f=0.0010 + +Air: d=1.29 mg/cm3 ; n=4 ; state=gas + +el: name=Nitrogen; f=0.755268 + +el: name=Oxygen; f=0.231781 + +el: name=Argon; f=0.012827 + +el: name=Carbon; f=0.000124 + +Glass: d=2.5 g/cm3; n=4; state=solid + +el: name=Sodium ; f=0.1020 + +el: name=Calcium; f=0.0510 + +el: name=Silicon; f=0.2480 + +el: name=Oxygen; f=0.5990 + +Scinti-C9H10: d=1.032 g/cm3 ; n=2 + +el: name=Carbon; n=9 + +el: name=Hydrogen; n=10 + +LuYAP-70: d=7.1 g/cm3 ; n=4 + +el: name=Lutetium ; n= 7 + +el: name=Yttrium ; n= 3 + +el: name=Aluminium; n=10 + +el: name=Oxygen; n=30 + +LuYAP-80: d=7.5 g/cm3 ; n=4 + +el: name=Lutetium ; n= 8 + +el: name=Yttrium ; n= 2 + +el: name=Aluminium; n=10 + +el: name=Oxygen; n=30 + +Plastic: d=1.18 g/cm3 ; n=3; state=solid + +el: name=Carbon ; n=5 + +el: name=Hydrogen ; n=8 + +el: name=Oxygen ; n=2 + +CZT: d=5.68 g/cm3 ; n=3; state=solid + +el: name=Cadmium ; n=9 + +el: name=Zinc ; n=1 + +el: name=Tellurium ; n=10 + +Lung: d=0.26 g/cm3 ; n=9 + +el: name=Hydrogen ; f=0.103 + +el: name=Carbon ; f=0.105 + +el: name=Nitrogen ; f=0.031 + +el: name=Oxygen ; f=0.749 + +el: name=Sodium ; f=0.002 + +el: name=Phosphor ; f=0.002 + +el: name=Sulfur ; f=0.003 + +el: name=Chlorine ; f=0.003 + +el: name=Potassium ; f=0.002 + +Polyethylene: d=0.96 g/cm3 ; n=2 + +el: name=Hydrogen ; n=2 + +el: name=Carbon ; n=1 + +PVC: d=1.65 g/cm3 ; n=3 ; state=solid + +el: name=Hydrogen ; n=3 + +el: name=Carbon ; n=2 + +el: name=Chlorine ; n=1 + +SS304: d=7.92 g/cm3 ; n=4 ; state=solid + +el: name=Iron ; f=0.695 + +el: name=Chromium ; f=0.190 + +el: name=Nickel ; f=0.095 + +el: name=Manganese ; f=0.020 + +PTFE: d= 2.18 g/cm3 ; n=2 ; state=solid + +el: name=Carbon ; n=1 + +el: name=Fluorine ; n=2 + + +LYSO: d=5.37 g/cm3; n=4 ; state=Solid + +el: name=Lutetium ; f=0.31101534 + +el: name=Yttrium ; f=0.368765605 + +el: name=Silicon; f=0.083209699 + +el: name=Oxygen; f=0.237009356 + +Body: d=1.00 g/cm3 ; n=2 + +el: name=Hydrogen ; f=0.112 + +el: name=Oxygen ; f=0.888 + +Muscle: d=1.05 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.102 + +el: name=Carbon ; f=0.143 + +el: name=Nitrogen ; f=0.034 + +el: name=Oxygen ; f=0.71 + +el: name=Sodium ; f=0.001 + +el: name=Phosphor ; f=0.002 + +el: name=Sulfur ; f=0.003 + +el: name=Chlorine ; f=0.001 + +el: name=Potassium ; f=0.004 + +el: name=Calcium ; f=0.0 + +el: name=Scandium ; f=0.0 + +LungMoby: d=0.30 g/cm3 ; n=6 + +el: name=Hydrogen ; f=0.099 + +el: name=Carbon ; f=0.100 + +el: name=Nitrogen ; f=0.028 + +el: name=Oxygen ; f=0.740 + +el: name=Phosphor ; f=0.001 + +el: name=Calcium ; f=0.032 + +SpineBone: d=1.42 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.063 + +el: name=Carbon ; f=0.261 + +el: name=Nitrogen ; f=0.039 + +el: name=Oxygen ; f=0.436 + +el: name=Sodium ; f=0.001 + +el: name=Magnesium ; f=0.001 + +el: name=Phosphor ; f=0.061 + +el: name=Sulfur ; f=0.003 + +el: name=Chlorine ; f=0.001 + +el: name=Potassium ; f=0.001 + +el: name=Calcium ; f=0.133 + +RibBone: d=1.92 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.034 + +el: name=Carbon ; f=0.155 + +el: name=Nitrogen ; f=0.042 + +el: name=Oxygen ; f=0.435 + +el: name=Sodium ; f=0.001 + +el: name=Magnesium ; f=0.002 + +el: name=Phosphor ; f=0.103 + +el: name=Sulfur ; f=0.003 + +el: name=Calcium ; f=0.225 + +el: name=Scandium ; f=0.0 + +el: name=Titanium ; f=0.0 + +Adipose: d=0.92 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.120 + +el: name=Carbon ; f=0.640 + +el: name=Nitrogen ; f=0.008 + +el: name=Oxygen ; f=0.229 + +el: name=Phosphor ; f=0.002 + +el: name=Calcium ; f=0.001 + +el: name=Scandium ; f=0.0 + +el: name=Titanium ; f=0.0 + +el: name=Vandium ; f=0.0 + +el: name=Chromium ; f=0.0 + +el: name=Manganese ; f=0.0 + +Blood: d=1.06 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.102 + +el: name=Carbon ; f=0.11 + +el: name=Nitrogen ; f=0.033 + +el: name=Oxygen ; f=0.745 + +el: name=Sodium ; f=0.001 + +el: name=Phosphor ; f=0.001 + +el: name=Sulfur ; f=0.002 + +el: name=Chlorine ; f=0.003 + +el: name=Potassium ; f=0.002 + +el: name=Iron ; f=0.001 + +el: name=Cobalt ; f=0.0 + +Heart: d=1.05 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.104 + +el: name=Carbon ; f=0.139 + +el: name=Nitrogen ; f=0.029 + +el: name=Oxygen ; f=0.718 + +el: name=Sodium ; f=0.001 + +el: name=Phosphor ; f=0.002 + +el: name=Sulfur ; f=0.002 + +el: name=Chlorine ; f=0.002 + +el: name=Potassium ; f=0.003 + +el: name=Calcium ; f=0.0 + +el: name=Scandium ; f=0.0 + +Kidney: d=1.05 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.103 + +el: name=Carbon ; f=0.132 + +el: name=Nitrogen ; f=0.03 + +el: name=Oxygen ; f=0.724 + +el: name=Sodium ; f=0.002 + +el: name=Phosphor ; f=0.002 + +el: name=Sulfur ; f=0.002 + +el: name=Chlorine ; f=0.002 + +el: name=Potassium ; f=0.002 + +el: name=Calcium ; f=0.001 + +el: name=Scandium ; f=0.0 + +Liver: d=1.06 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.102 + +el: name=Carbon ; f=0.139 + +el: name=Nitrogen ; f=0.03 + +el: name=Oxygen ; f=0.716 + +el: name=Sodium ; f=0.002 + +el: name=Phosphor ; f=0.003 + +el: name=Sulfur ; f=0.003 + +el: name=Chlorine ; f=0.002 + +el: name=Potassium ; f=0.003 + +el: name=Calcium ; f=0.0 + +el: name=Scandium ; f=0.0 + +Lymph: d=1.03 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.108 + +el: name=Carbon ; f=0.041 + +el: name=Nitrogen ; f=0.011 + +el: name=Oxygen ; f=0.832 + +el: name=Sodium ; f=0.003 + +el: name=Sulfur ; f=0.001 + +el: name=Chlorine ; f=0.004 + +el: name=Argon ; f=0.0 + +el: name=Potassium ; f=0.0 + +el: name=Calcium ; f=0.0 + +el: name=Scandium ; f=0.0 + +Pancreas: d=1.04 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.106 + +el: name=Carbon ; f=0.169 + +el: name=Nitrogen ; f=0.022 + +el: name=Oxygen ; f=0.694 + +el: name=Sodium ; f=0.002 + +el: name=Phosphor ; f=0.002 + +el: name=Sulfur ; f=0.001 + +el: name=Chlorine ; f=0.002 + +el: name=Potassium ; f=0.002 + +el: name=Calcium ; f=0.0 + +el: name=Scandium ; f=0.0 + +Intestine: d=1.03 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.106 + +el: name=Carbon ; f=0.115 + +el: name=Nitrogen ; f=0.022 + +el: name=Oxygen ; f=0.751 + +el: name=Sodium ; f=0.001 + +el: name=Phosphor ; f=0.001 + +el: name=Sulfur ; f=0.001 + +el: name=Chlorine ; f=0.002 + +el: name=Potassium ; f=0.001 + +el: name=Calcium ; f=0.0 + +el: name=Scandium ; f=0.0 + +Skull: d=1.61 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.05 + +el: name=Carbon ; f=0.212 + +el: name=Nitrogen ; f=0.04 + +el: name=Oxygen ; f=0.435 + +el: name=Sodium ; f=0.001 + +el: name=Magnesium ; f=0.002 + +el: name=Phosphor ; f=0.081 + +el: name=Sulfur ; f=0.003 + +el: name=Calcium ; f=0.176 + +el: name=Scandium ; f=0.0 + +el: name=Titanium ; f=0.0 + +Cartilage: d=1.10 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.096 + +el: name=Carbon ; f=0.099 + +el: name=Nitrogen ; f=0.022 + +el: name=Oxygen ; f=0.744 + +el: name=Sodium ; f=0.005 + +el: name=Phosphor ; f=0.022 + +el: name=Sulfur ; f=0.009 + +el: name=Chlorine ; f=0.003 + +el: name=Argon ; f=0.0 + +el: name=Potassium ; f=0.0 + +el: name=Calcium ; f=0.0 + +Brain: d=1.04 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.107 + +el: name=Carbon ; f=0.145 + +el: name=Nitrogen ; f=0.022 + +el: name=Oxygen ; f=0.712 + +el: name=Sodium ; f=0.002 + +el: name=Phosphor ; f=0.004 + +el: name=Sulfur ; f=0.002 + +el: name=Chlorine ; f=0.003 + +el: name=Potassium ; f=0.003 + +el: name=Calcium ; f=0.0 + +el: name=Scandium ; f=0.0 + +Spleen: d=1.06 g/cm3 ; n=11 + +el: name=Hydrogen ; f=0.103 + +el: name=Carbon ; f=0.113 + +el: name=Nitrogen ; f=0.032 + +el: name=Oxygen ; f=0.741 + +el: name=Sodium ; f=0.001 + +el: name=Phosphor ; f=0.003 + +el: name=Sulfur ; f=0.002 + +el: name=Chlorine ; f=0.002 + +el: name=Potassium ; f=0.003 + +el: name=Calcium ; f=0.0 + +el: name=Scandium ; f=0.0 + +Testis: d=1.04 g/cm3 ; n=9 + +el: name=Hydrogen ; f=0.106000 + +el: name=Carbon ; f=0.099000 + +el: name=Nitrogen ; f=0.020000 + +el: name=Oxygen ; f=0.766000 + +el: name=Sodium ; f=0.002000 + +el: name=Phosphor ; f=0.001000 + +el: name=Sulfur ; f=0.002000 + +el: name=Chlorine ; f=0.002000 + +el: name=Potassium ; f=0.002000 + +PMMA: d=1.195 g/cm3; n=3 ; state=Solid + +el: name=Hydrogen ; f=0.080541 + +el: name=Carbon ; f=0.599846 + +el: name=Sulfur; f=0.319613 diff --git a/pytheranostics/data/monte_carlo/data/Schneider2000DensitiesTable.txt b/pytheranostics/data/monte_carlo/data/Schneider2000DensitiesTable.txt new file mode 100644 index 0000000..2e12d18 --- /dev/null +++ b/pytheranostics/data/monte_carlo/data/Schneider2000DensitiesTable.txt @@ -0,0 +1,13 @@ +# =================== +# HU density g/cm3 +# =================== +-1000 1.21e-3 +-98 0.93 +-97 0.930486 +14 1.03 +23 1.031 +100 1.119900 +101 1.076200 +1600 1.964200 +3000 2.8 +3200 2.9 diff --git a/pytheranostics/data/monte_carlo/data/Schneider2000MaterialsTable.txt b/pytheranostics/data/monte_carlo/data/Schneider2000MaterialsTable.txt new file mode 100644 index 0000000..c6a2ac7 --- /dev/null +++ b/pytheranostics/data/monte_carlo/data/Schneider2000MaterialsTable.txt @@ -0,0 +1,36 @@ +[Elements] +Hydrogen Carbon Nitrogen Oxygen Sodium Magnesium Phosphor Sulfur +Chlorine Argon Potassium Calcium +Titanium Copper Zinc Silver Tin +[/Elements] +# =============================================================================== +# HU H C N O Na Mg P S Cl Ar K Ca Ti Cu Zn Ag Sn +# =============================================================================== + -1050 0 0 75.5 23.2 0 0 0 0 0 1.3 0 0 0 0 0 0 0 Air + -950 10.3 10.5 3.1 74.9 0.2 0 0.2 0.3 0.3 0 0.2 0 0 0 0 0 0 Lung + -120 11.6 68.1 0.2 19.8 0.1 0 0 0.1 0.1 0 0 0 0 0 0 0 0 AT_AG_SI1 + -82 11.3 56.7 0.9 30.8 0.1 0 0 0.1 0.1 0 0 0 0 0 0 0 0 AT_AG_SI2 + -52 11.0 45.8 1.5 41.1 0.1 0 0.1 0.2 0.2 0 0 0 0 0 0 0 0 AT_AG_SI3 + -22 10.8 35.6 2.2 50.9 0 0 0.1 0.2 0.2 0 0 0 0 0 0 0 0 AT_AG_SI4 + 8 10.6 28.4 2.6 57.8 0 0 0.1 0.2 0.2 0 0.1 0 0 0 0 0 0 AT_AG_SI5 + 19 10.3 13.4 3.0 72.3 0.2 0 0.2 0.2 0.2 0 0.2 0 0 0 0 0 0 SoftTissus + 80 9.4 20.7 6.2 62.2 0.6 0 0 0.6 0.3 0 0.0 0 0 0 0 0 0 ConnectiveTissue + 120 9.5 45.5 2.5 35.5 0.1 0 2.1 0.1 0.1 0 0.1 4.5 0 0 0 0 0 Marrow_Bone01 + 200 8.9 42.3 2.7 36.3 0.1 0 3.0 0.1 0.1 0 0.1 6.4 0 0 0 0 0 Marrow_Bone02 + 300 8.2 39.1 2.9 37.2 0.1 0 3.9 0.1 0.1 0 0.1 8.3 0 0 0 0 0 Marrow_Bone03 + 400 7.6 36.1 3.0 38.0 0.1 0.1 4.7 0.2 0.1 0 0 10.1 0 0 0 0 0 Marrow_Bone04 + 500 7.1 33.5 3.2 38.7 0.1 0.1 5.4 0.2 0 0 0 11.7 0 0 0 0 0 Marrow_Bone05 + 600 6.6 31.0 3.3 39.4 0.1 0.1 6.1 0.2 0 0 0 13.2 0 0 0 0 0 Marrow_Bone06 + 700 6.1 28.7 3.5 40.0 0.1 0.1 6.7 0.2 0 0 0 14.6 0 0 0 0 0 Marrow_Bone07 + 800 5.6 26.5 3.6 40.5 0.1 0.2 7.3 0.3 0 0 0 15.9 0 0 0 0 0 Marrow_Bone08 + 900 5.2 24.6 3.7 41.1 0.1 0.2 7.8 0.3 0 0 0 17.0 0 0 0 0 0 Marrow_Bone09 + 1000 4.9 22.7 3.8 41.6 0.1 0.2 8.3 0.3 0 0 0 18.1 0 0 0 0 0 Marrow_Bone10 + 1100 4.5 21.0 3.9 42.0 0.1 0.2 8.8 0.3 0 0 0 19.2 0 0 0 0 0 Marrow_Bone11 + 1200 4.2 19.4 4.0 42.5 0.1 0.2 9.2 0.3 0 0 0 20.1 0 0 0 0 0 Marrow_Bone12 + 1300 3.9 17.9 4.1 42.9 0.1 0.2 9.6 0.3 0 0 0 21.0 0 0 0 0 0 Marrow_Bone13 + 1400 3.6 16.5 4.2 43.2 0.1 0.2 10.0 0.3 0 0 0 21.9 0 0 0 0 0 Marrow_Bone14 + 1500 3.4 15.5 4.2 43.5 0.1 0.2 10.3 0.3 0 0 0 22.5 0 0 0 0 0 Marrow_Bone15 + 1640 0 0 0 0 0 0 0 0 0 0 0 0 0 4 2 65 29 AmalgamTooth + 2300 0 0 0 0 0 0 0 0 0 0 0 0 100 0 0 0 0 MetallImplants + 3000 0 0 0 0 0 0 0 0 0 0 0 0 100 0 0 0 0 MetallImplants + 4000 0 0 0 0 0 0 0 0 0 0 0 0 100 0 0 0 0 MetallImplants diff --git a/pytheranostics/data/monte_carlo/data/patient-HU2mat.txt b/pytheranostics/data/monte_carlo/data/patient-HU2mat.txt new file mode 100644 index 0000000..20495f9 --- /dev/null +++ b/pytheranostics/data/monte_carlo/data/patient-HU2mat.txt @@ -0,0 +1,44 @@ +-1050 -950 Air_0 +-950 -852.884 Lung_1 +-852.884 -755.769 Lung_2 +-755.769 -658.653 Lung_3 +-658.653 -561.538 Lung_4 +-561.538 -464.422 Lung_5 +-464.422 -367.306 Lung_6 +-367.306 -270.191 Lung_7 +-270.191 -173.075 Lung_8 +-173.075 -120 Lung_9 +-120 -82 AT_AG_SI1_10 +-82 -52 AT_AG_SI2_11 +-52 -22 AT_AG_SI3_12 +-22 8 AT_AG_SI4_13 +8 19 AT_AG_SI5_14 +19 80 SoftTissus_15 +80 120 ConnectiveTissue_16 +120 200 Marrow_Bone01_17 +200 300 Marrow_Bone02_18 +300 400 Marrow_Bone03_19 +400 500 Marrow_Bone04_20 +500 600 Marrow_Bone05_21 +600 700 Marrow_Bone06_22 +700 800 Marrow_Bone07_23 +800 900 Marrow_Bone08_24 +900 1000 Marrow_Bone09_25 +1000 1100 Marrow_Bone10_26 +1100 1200 Marrow_Bone11_27 +1200 1300 Marrow_Bone12_28 +1300 1400 Marrow_Bone13_29 +1400 1500 Marrow_Bone14_30 +1500 1640 Marrow_Bone15_31 +1640 1807.5 AmalgamTooth_32 +1807.5 1975.01 AmalgamTooth_33 +1975.01 2142.51 AmalgamTooth_34 +2142.51 2300 AmalgamTooth_35 +2300 2467.5 MetallImplants_36 +2467.5 2635.01 MetallImplants_37 +2635.01 2802.51 MetallImplants_38 +2802.51 2970.02 MetallImplants_39 +2970.02 3000 MetallImplants_40 +3000 4000 MetallImplants_41 +4000 4000 MetallImplants_42 +4000 4001 MetallImplants_43 diff --git a/pytheranostics/data/monte_carlo/data/patient-HUmaterials.db b/pytheranostics/data/monte_carlo/data/patient-HUmaterials.db new file mode 100644 index 0000000..a4bbd0f --- /dev/null +++ b/pytheranostics/data/monte_carlo/data/patient-HUmaterials.db @@ -0,0 +1,432 @@ +[Materials] +# Material corresponding to H=[ -1050;-950 ] +Air_0: d=1.21 mg/cm3; n=3; ++el: name=Nitrogen; f=0.755 ++el: name=Oxygen; f=0.232 ++el: name=Argon; f=0.013 + +# Material corresponding to H=[ -950;-852.884 ] +Lung_1: d=102.695 mg/cm3; n=9; ++el: name=Hydrogen; f=0.103 ++el: name=Carbon; f=0.105 ++el: name=Nitrogen; f=0.031 ++el: name=Oxygen; f=0.749 ++el: name=Sodium; f=0.002 ++el: name=Phosphor; f=0.002 ++el: name=Sulfur; f=0.003 ++el: name=Chlorine; f=0.003 ++el: name=Potassium; f=0.002 + +# Material corresponding to H=[ -852.884;-755.769 ] +Lung_2: d=202.695 mg/cm3; n=9; ++el: name=Hydrogen; f=0.103 ++el: name=Carbon; f=0.105 ++el: name=Nitrogen; f=0.031 ++el: name=Oxygen; f=0.749 ++el: name=Sodium; f=0.002 ++el: name=Phosphor; f=0.002 ++el: name=Sulfur; f=0.003 ++el: name=Chlorine; f=0.003 ++el: name=Potassium; f=0.002 + +# Material corresponding to H=[ -755.769;-658.653 ] +Lung_3: d=302.695 mg/cm3; n=9; ++el: name=Hydrogen; f=0.103 ++el: name=Carbon; f=0.105 ++el: name=Nitrogen; f=0.031 ++el: name=Oxygen; f=0.749 ++el: name=Sodium; f=0.002 ++el: name=Phosphor; f=0.002 ++el: name=Sulfur; f=0.003 ++el: name=Chlorine; f=0.003 ++el: name=Potassium; f=0.002 + +# Material corresponding to H=[ -658.653;-561.538 ] +Lung_4: d=402.695 mg/cm3; n=9; ++el: name=Hydrogen; f=0.103 ++el: name=Carbon; f=0.105 ++el: name=Nitrogen; f=0.031 ++el: name=Oxygen; f=0.749 ++el: name=Sodium; f=0.002 ++el: name=Phosphor; f=0.002 ++el: name=Sulfur; f=0.003 ++el: name=Chlorine; f=0.003 ++el: name=Potassium; f=0.002 + +# Material corresponding to H=[ -561.538;-464.422 ] +Lung_5: d=502.695 mg/cm3; n=9; ++el: name=Hydrogen; f=0.103 ++el: name=Carbon; f=0.105 ++el: name=Nitrogen; f=0.031 ++el: name=Oxygen; f=0.749 ++el: name=Sodium; f=0.002 ++el: name=Phosphor; f=0.002 ++el: name=Sulfur; f=0.003 ++el: name=Chlorine; f=0.003 ++el: name=Potassium; f=0.002 + +# Material corresponding to H=[ -464.422;-367.306 ] +Lung_6: d=602.695 mg/cm3; n=9; ++el: name=Hydrogen; f=0.103 ++el: name=Carbon; f=0.105 ++el: name=Nitrogen; f=0.031 ++el: name=Oxygen; f=0.749 ++el: name=Sodium; f=0.002 ++el: name=Phosphor; f=0.002 ++el: name=Sulfur; f=0.003 ++el: name=Chlorine; f=0.003 ++el: name=Potassium; f=0.002 + +# Material corresponding to H=[ -367.306;-270.191 ] +Lung_7: d=702.695 mg/cm3; n=9; ++el: name=Hydrogen; f=0.103 ++el: name=Carbon; f=0.105 ++el: name=Nitrogen; f=0.031 ++el: name=Oxygen; f=0.749 ++el: name=Sodium; f=0.002 ++el: name=Phosphor; f=0.002 ++el: name=Sulfur; f=0.003 ++el: name=Chlorine; f=0.003 ++el: name=Potassium; f=0.002 + +# Material corresponding to H=[ -270.191;-173.075 ] +Lung_8: d=802.695 mg/cm3; n=9; ++el: name=Hydrogen; f=0.103 ++el: name=Carbon; f=0.105 ++el: name=Nitrogen; f=0.031 ++el: name=Oxygen; f=0.749 ++el: name=Sodium; f=0.002 ++el: name=Phosphor; f=0.002 ++el: name=Sulfur; f=0.003 ++el: name=Chlorine; f=0.003 ++el: name=Potassium; f=0.002 + +# Material corresponding to H=[ -173.075;-120 ] +Lung_9: d=880.021 mg/cm3; n=9; ++el: name=Hydrogen; f=0.103 ++el: name=Carbon; f=0.105 ++el: name=Nitrogen; f=0.031 ++el: name=Oxygen; f=0.749 ++el: name=Sodium; f=0.002 ++el: name=Phosphor; f=0.002 ++el: name=Sulfur; f=0.003 ++el: name=Chlorine; f=0.003 ++el: name=Potassium; f=0.002 + +# Material corresponding to H=[ -120;-82 ] +AT_AG_SI1_10: d=926.911 mg/cm3; n=7; ++el: name=Hydrogen; f=0.116 ++el: name=Carbon; f=0.681 ++el: name=Nitrogen; f=0.002 ++el: name=Oxygen; f=0.198 ++el: name=Sodium; f=0.001 ++el: name=Sulfur; f=0.001 ++el: name=Chlorine; f=0.001 + +# Material corresponding to H=[ -82;-52 ] +AT_AG_SI2_11: d=957.382 mg/cm3; n=7; ++el: name=Hydrogen; f=0.113 ++el: name=Carbon; f=0.567 ++el: name=Nitrogen; f=0.009 ++el: name=Oxygen; f=0.308 ++el: name=Sodium; f=0.001 ++el: name=Sulfur; f=0.001 ++el: name=Chlorine; f=0.001 + +# Material corresponding to H=[ -52;-22 ] +AT_AG_SI3_12: d=984.277 mg/cm3; n=8; ++el: name=Hydrogen; f=0.11 ++el: name=Carbon; f=0.458 ++el: name=Nitrogen; f=0.015 ++el: name=Oxygen; f=0.411 ++el: name=Sodium; f=0.001 ++el: name=Phosphor; f=0.001 ++el: name=Sulfur; f=0.002 ++el: name=Chlorine; f=0.002 + +# Material corresponding to H=[ -22;8 ] +AT_AG_SI4_13: d=1.01117 g/cm3 ; n=7; ++el: name=Hydrogen; f=0.108 ++el: name=Carbon; f=0.356 ++el: name=Nitrogen; f=0.022 ++el: name=Oxygen; f=0.509 ++el: name=Phosphor; f=0.001 ++el: name=Sulfur; f=0.002 ++el: name=Chlorine; f=0.002 + +# Material corresponding to H=[ 8;19 ] +AT_AG_SI5_14: d=1.02955 g/cm3 ; n=8; ++el: name=Hydrogen; f=0.106 ++el: name=Carbon; f=0.284 ++el: name=Nitrogen; f=0.026 ++el: name=Oxygen; f=0.578 ++el: name=Phosphor; f=0.001 ++el: name=Sulfur; f=0.002 ++el: name=Chlorine; f=0.002 ++el: name=Potassium; f=0.001 + +# Material corresponding to H=[ 19;80 ] +SoftTissus_15: d=1.0616 g/cm3 ; n=9; ++el: name=Hydrogen; f=0.103 ++el: name=Carbon; f=0.134 ++el: name=Nitrogen; f=0.03 ++el: name=Oxygen; f=0.723 ++el: name=Sodium; f=0.002 ++el: name=Phosphor; f=0.002 ++el: name=Sulfur; f=0.002 ++el: name=Chlorine; f=0.002 ++el: name=Potassium; f=0.002 + +# Material corresponding to H=[ 80;120 ] +ConnectiveTissue_16: d=1.1199 g/cm3 ; n=7; ++el: name=Hydrogen; f=0.094 ++el: name=Carbon; f=0.207 ++el: name=Nitrogen; f=0.062 ++el: name=Oxygen; f=0.622 ++el: name=Sodium; f=0.006 ++el: name=Sulfur; f=0.006 ++el: name=Chlorine; f=0.003 + +# Material corresponding to H=[ 120;200 ] +Marrow_Bone01_17: d=1.11115 g/cm3 ; n=10; ++el: name=Hydrogen; f=0.095 ++el: name=Carbon; f=0.455 ++el: name=Nitrogen; f=0.025 ++el: name=Oxygen; f=0.355 ++el: name=Sodium; f=0.001 ++el: name=Phosphor; f=0.021 ++el: name=Sulfur; f=0.001 ++el: name=Chlorine; f=0.001 ++el: name=Potassium; f=0.001 ++el: name=Calcium; f=0.045 + +# Material corresponding to H=[ 200;300 ] +Marrow_Bone02_18: d=1.16447 g/cm3 ; n=10; ++el: name=Hydrogen; f=0.089 ++el: name=Carbon; f=0.423 ++el: name=Nitrogen; f=0.027 ++el: name=Oxygen; f=0.363 ++el: name=Sodium; f=0.001 ++el: name=Phosphor; f=0.03 ++el: name=Sulfur; f=0.001 ++el: name=Chlorine; f=0.001 ++el: name=Potassium; f=0.001 ++el: name=Calcium; f=0.064 + +# Material corresponding to H=[ 300;400 ] +Marrow_Bone03_19: d=1.22371 g/cm3 ; n=10; ++el: name=Hydrogen; f=0.082 ++el: name=Carbon; f=0.391 ++el: name=Nitrogen; f=0.029 ++el: name=Oxygen; f=0.372 ++el: name=Sodium; f=0.001 ++el: name=Phosphor; f=0.039 ++el: name=Sulfur; f=0.001 ++el: name=Chlorine; f=0.001 ++el: name=Potassium; f=0.001 ++el: name=Calcium; f=0.083 + +# Material corresponding to H=[ 400;500 ] +Marrow_Bone04_20: d=1.28295 g/cm3 ; n=10; ++el: name=Hydrogen; f=0.076 ++el: name=Carbon; f=0.361 ++el: name=Nitrogen; f=0.03 ++el: name=Oxygen; f=0.38 ++el: name=Sodium; f=0.001 ++el: name=Magnesium; f=0.001 ++el: name=Phosphor; f=0.047 ++el: name=Sulfur; f=0.002 ++el: name=Chlorine; f=0.001 ++el: name=Calcium; f=0.101 + +# Material corresponding to H=[ 500;600 ] +Marrow_Bone05_21: d=1.34219 g/cm3 ; n=9; ++el: name=Hydrogen; f=0.071 ++el: name=Carbon; f=0.335 ++el: name=Nitrogen; f=0.032 ++el: name=Oxygen; f=0.387 ++el: name=Sodium; f=0.001 ++el: name=Magnesium; f=0.001 ++el: name=Phosphor; f=0.054 ++el: name=Sulfur; f=0.002 ++el: name=Calcium; f=0.117 + +# Material corresponding to H=[ 600;700 ] +Marrow_Bone06_22: d=1.40142 g/cm3 ; n=9; ++el: name=Hydrogen; f=0.066 ++el: name=Carbon; f=0.31 ++el: name=Nitrogen; f=0.033 ++el: name=Oxygen; f=0.394 ++el: name=Sodium; f=0.001 ++el: name=Magnesium; f=0.001 ++el: name=Phosphor; f=0.061 ++el: name=Sulfur; f=0.002 ++el: name=Calcium; f=0.132 + +# Material corresponding to H=[ 700;800 ] +Marrow_Bone07_23: d=1.46066 g/cm3 ; n=9; ++el: name=Hydrogen; f=0.061 ++el: name=Carbon; f=0.287 ++el: name=Nitrogen; f=0.035 ++el: name=Oxygen; f=0.4 ++el: name=Sodium; f=0.001 ++el: name=Magnesium; f=0.001 ++el: name=Phosphor; f=0.067 ++el: name=Sulfur; f=0.002 ++el: name=Calcium; f=0.146 + +# Material corresponding to H=[ 800;900 ] +Marrow_Bone08_24: d=1.5199 g/cm3 ; n=9; ++el: name=Hydrogen; f=0.056 ++el: name=Carbon; f=0.265 ++el: name=Nitrogen; f=0.036 ++el: name=Oxygen; f=0.405 ++el: name=Sodium; f=0.001 ++el: name=Magnesium; f=0.002 ++el: name=Phosphor; f=0.073 ++el: name=Sulfur; f=0.003 ++el: name=Calcium; f=0.159 + +# Material corresponding to H=[ 900;1000 ] +Marrow_Bone09_25: d=1.57914 g/cm3 ; n=9; ++el: name=Hydrogen; f=0.052 ++el: name=Carbon; f=0.246 ++el: name=Nitrogen; f=0.037 ++el: name=Oxygen; f=0.411 ++el: name=Sodium; f=0.001 ++el: name=Magnesium; f=0.002 ++el: name=Phosphor; f=0.078 ++el: name=Sulfur; f=0.003 ++el: name=Calcium; f=0.17 + +# Material corresponding to H=[ 1000;1100 ] +Marrow_Bone10_26: d=1.63838 g/cm3 ; n=9; ++el: name=Hydrogen; f=0.049 ++el: name=Carbon; f=0.227 ++el: name=Nitrogen; f=0.038 ++el: name=Oxygen; f=0.416 ++el: name=Sodium; f=0.001 ++el: name=Magnesium; f=0.002 ++el: name=Phosphor; f=0.083 ++el: name=Sulfur; f=0.003 ++el: name=Calcium; f=0.181 + +# Material corresponding to H=[ 1100;1200 ] +Marrow_Bone11_27: d=1.69762 g/cm3 ; n=9; ++el: name=Hydrogen; f=0.045 ++el: name=Carbon; f=0.21 ++el: name=Nitrogen; f=0.039 ++el: name=Oxygen; f=0.42 ++el: name=Sodium; f=0.001 ++el: name=Magnesium; f=0.002 ++el: name=Phosphor; f=0.088 ++el: name=Sulfur; f=0.003 ++el: name=Calcium; f=0.192 + +# Material corresponding to H=[ 1200;1300 ] +Marrow_Bone12_28: d=1.75686 g/cm3 ; n=9; ++el: name=Hydrogen; f=0.042 ++el: name=Carbon; f=0.194 ++el: name=Nitrogen; f=0.04 ++el: name=Oxygen; f=0.425 ++el: name=Sodium; f=0.001 ++el: name=Magnesium; f=0.002 ++el: name=Phosphor; f=0.092 ++el: name=Sulfur; f=0.003 ++el: name=Calcium; f=0.201 + +# Material corresponding to H=[ 1300;1400 ] +Marrow_Bone13_29: d=1.8161 g/cm3 ; n=9; ++el: name=Hydrogen; f=0.039 ++el: name=Carbon; f=0.179 ++el: name=Nitrogen; f=0.041 ++el: name=Oxygen; f=0.429 ++el: name=Sodium; f=0.001 ++el: name=Magnesium; f=0.002 ++el: name=Phosphor; f=0.096 ++el: name=Sulfur; f=0.003 ++el: name=Calcium; f=0.21 + +# Material corresponding to H=[ 1400;1500 ] +Marrow_Bone14_30: d=1.87534 g/cm3 ; n=9; ++el: name=Hydrogen; f=0.036 ++el: name=Carbon; f=0.165 ++el: name=Nitrogen; f=0.042 ++el: name=Oxygen; f=0.432 ++el: name=Sodium; f=0.001 ++el: name=Magnesium; f=0.002 ++el: name=Phosphor; f=0.1 ++el: name=Sulfur; f=0.003 ++el: name=Calcium; f=0.219 + +# Material corresponding to H=[ 1500;1640 ] +Marrow_Bone15_31: d=1.94643 g/cm3 ; n=9; ++el: name=Hydrogen; f=0.034 ++el: name=Carbon; f=0.155 ++el: name=Nitrogen; f=0.042 ++el: name=Oxygen; f=0.435 ++el: name=Sodium; f=0.001 ++el: name=Magnesium; f=0.002 ++el: name=Phosphor; f=0.103 ++el: name=Sulfur; f=0.003 ++el: name=Calcium; f=0.225 + +# Material corresponding to H=[ 1640;1807.5 ] +AmalgamTooth_32: d=2.03808 g/cm3 ; n=4; ++el: name=Copper; f=0.04 ++el: name=Zinc; f=0.02 ++el: name=Silver; f=0.65 ++el: name=Tin; f=0.29 + +# Material corresponding to H=[ 1807.5;1975.01 ] +AmalgamTooth_33: d=2.13808 g/cm3 ; n=4; ++el: name=Copper; f=0.04 ++el: name=Zinc; f=0.02 ++el: name=Silver; f=0.65 ++el: name=Tin; f=0.29 + +# Material corresponding to H=[ 1975.01;2142.51 ] +AmalgamTooth_34: d=2.23808 g/cm3 ; n=4; ++el: name=Copper; f=0.04 ++el: name=Zinc; f=0.02 ++el: name=Silver; f=0.65 ++el: name=Tin; f=0.29 + +# Material corresponding to H=[ 2142.51;2300 ] +AmalgamTooth_35: d=2.33509 g/cm3 ; n=4; ++el: name=Copper; f=0.04 ++el: name=Zinc; f=0.02 ++el: name=Silver; f=0.65 ++el: name=Tin; f=0.29 + +# Material corresponding to H=[ 2300;2467.5 ] +MetallImplants_36: d=2.4321 g/cm3 ; n=1; ++el: name=Titanium; f=1 + +# Material corresponding to H=[ 2467.5;2635.01 ] +MetallImplants_37: d=2.5321 g/cm3 ; n=1; ++el: name=Titanium; f=1 + +# Material corresponding to H=[ 2635.01;2802.51 ] +MetallImplants_38: d=2.6321 g/cm3 ; n=1; ++el: name=Titanium; f=1 + +# Material corresponding to H=[ 2802.51;2970.02 ] +MetallImplants_39: d=2.7321 g/cm3 ; n=1; ++el: name=Titanium; f=1 + +# Material corresponding to H=[ 2970.02;3000 ] +MetallImplants_40: d=2.79105 g/cm3 ; n=1; ++el: name=Titanium; f=1 + +# Material corresponding to H=[ 3000;4000 ] +MetallImplants_41: d=2.9 g/cm3 ; n=1; ++el: name=Titanium; f=1 + +# Material corresponding to H=[ 4000;4000 ] +MetallImplants_42: d=2.9 g/cm3 ; n=1; ++el: name=Titanium; f=1 + +# Material corresponding to H=[ 4000;4001 ] +MetallImplants_43: d=2.9 g/cm3 ; n=1; ++el: name=Titanium; f=1 diff --git a/doodle/dosimetry/main_template.mac b/pytheranostics/data/monte_carlo/main_template.mac similarity index 93% rename from doodle/dosimetry/main_template.mac rename to pytheranostics/data/monte_carlo/main_template.mac index 4840a67..6c8dda4 100644 --- a/doodle/dosimetry/main_template.mac +++ b/pytheranostics/data/monte_carlo/main_template.mac @@ -1,5 +1,5 @@ #======================================================================================== -# Verbosity +# Verbosity #======================================================================================== /gate/verbose Physic 1 @@ -17,7 +17,7 @@ /gate/verbose Geometry 2 #======================================================================================== -# Geometry +# Geometry #======================================================================================== # Material database /gate/geometry/setMaterialDatabase data/GateMaterials.db @@ -37,7 +37,7 @@ /gate/HounsfieldMaterialGenerator/SetOutputHUMaterialFilename data/patient-HU2mat.txt /gate/HounsfieldMaterialGenerator/Generate -# Voxelized geometry +# Voxelized geometry /gate/world/daughters/name phantom /gate/world/daughters/insert ImageNestedParametrisedVolume /gate/geometry/setMaterialDatabase data/patient-HUmaterials.db @@ -47,9 +47,9 @@ #======================================================================================== -# PHYSICS +# PHYSICS #======================================================================================== -#emstandard_opt3 is recommended for medical, space +#emstandard_opt3 is recommended for medical, space # http://geant4.in2p3.fr/IMG/pdf_PhysicsLists.pdf /gate/physics/addPhysicsList emstandard_opt3 /gate/physics/addProcess Decay @@ -65,7 +65,7 @@ #======================================================================================== -# DOSE ACTOR +# DOSE ACTOR #======================================================================================== /gate/actor/addActor DoseActor dose3D @@ -88,14 +88,14 @@ /gate/actor/stat/saveEveryNSeconds 3600 #======================================================================================== -# INITIALIZE +# INITIALIZE #======================================================================================== /gate/run/initialize /gate/physics/displayCuts #======================================================================================== -# SOURCE +# SOURCE #======================================================================================== @@ -104,7 +104,7 @@ /gate/source/Lu177Source/imageReader/translator/insert linear # Input image is normalized accumulated activity image - to make sure GATE is reading in the image correctly, set scale to 1 -# then check verbose output for source - is there the total activity in 1 Bq? Then it was read correctly. +# then check verbose output for source - is there the total activity in 1 Bq? Then it was read correctly. # In any way, GATE is simulating the number of primaries! /gate/source/Lu177Source/imageReader/linearTranslator/setScale 1 Bq /gate/source/Lu177Source/imageReader/readFile data/Source_normalized.mhd @@ -117,15 +117,15 @@ /gate/source/Lu177Source/gps/ion 71 177 0 0 /gate/source/Lu177Source/gps/angtype iso /gate/source/Lu177Source/setForcedUnstableFlag true -/gate/source/Lu177Source/setForcedHalfLife 574300.8 s +/gate/source/Lu177Source/setForcedHalfLife 574300.8 s /gate/source/Lu177Source/gps/energytype Mono /gate/source/Lu177Source/gps/monoenergy 0. keV # source is now relative to phantom instead of relative to world -/gate/source/Lu177Source/attachTo phantom +/gate/source/Lu177Source/attachTo phantom -# To enable information output of source +# To enable information output of source /gate/source/Lu177Source/dump 1 # Gives a list of all defined sources. In this example only 1 source @@ -144,4 +144,3 @@ /gate/application/setTotalNumberOfPrimaries XXX /gate/application/start - diff --git a/doodle/dosimetry/runsimulation1.sh b/pytheranostics/data/monte_carlo/runsimulation1.sh similarity index 99% rename from doodle/dosimetry/runsimulation1.sh rename to pytheranostics/data/monte_carlo/runsimulation1.sh index 794931f..4d1eb7f 100644 --- a/doodle/dosimetry/runsimulation1.sh +++ b/pytheranostics/data/monte_carlo/runsimulation1.sh @@ -17,6 +17,3 @@ for number_splits in $( eval echo {1..$ncpu}); do done cd $currentPath - - - diff --git a/doodle/dosimetry/olindaTemplates/adult_female.cas b/pytheranostics/data/olinda/templates/human/adult_female.cas similarity index 99% rename from doodle/dosimetry/olindaTemplates/adult_female.cas rename to pytheranostics/data/olinda/templates/human/adult_female.cas index b36c6ff..35fe178 100644 --- a/doodle/dosimetry/olindaTemplates/adult_female.cas +++ b/pytheranostics/data/olinda/templates/human/adult_female.cas @@ -124,4 +124,4 @@ Total Body|0.0 [END KINETIC DATA] [END ICRP 89 Adult Female] [END MODELS] -[END CASE FILE] \ No newline at end of file +[END CASE FILE] diff --git a/doodle/dosimetry/olindaTemplates/adult_male.cas b/pytheranostics/data/olinda/templates/human/adult_male.cas similarity index 99% rename from doodle/dosimetry/olindaTemplates/adult_male.cas rename to pytheranostics/data/olinda/templates/human/adult_male.cas index e5791f4..ae0a178 100644 --- a/doodle/dosimetry/olindaTemplates/adult_male.cas +++ b/pytheranostics/data/olinda/templates/human/adult_male.cas @@ -124,4 +124,4 @@ Total Body|0.0 [END KINETIC DATA] [END ICRP 89 Adult Male] [END MODELS] -[END CASE FILE] \ No newline at end of file +[END CASE FILE] diff --git a/pytheranostics/data/olinda/templates/mouse/mouse25g.cas b/pytheranostics/data/olinda/templates/mouse/mouse25g.cas new file mode 100644 index 0000000..6bfa44b --- /dev/null +++ b/pytheranostics/data/olinda/templates/mouse/mouse25g.cas @@ -0,0 +1,127 @@ +Saved on 03.08.2018 at 13:04:40 +[BEGIN CASE FILE] +[BEGIN NUCLIDE SETTINGS] +USE_DECAY_SERIES|false +USE_LEGACY_DATA|false +[END NUCLIDE SETTINGS] +[BEGIN NUCLIDES] +Y-90| +[END NUCLIDES] +[BEGIN MODEL SETTINGS] +[END MODEL SETTINGS] +[BEGIN MODELS] +[BEGIN 25g Mouse] +25g Mouse|Z0 +[BEGIN 25g Mouse SOURCE ORGANS] +Adrenals|0.0|0.0 +Brain|0.46592|0.000233 +Breasts|0.0|0.0 +Esophagus|0.0|0.0 +Eyes|0.0|0.0 +Gallbladder Contents|0.0|0.0 +LLI Contents|0.58344|0.0 +Small Intestine|1.742|0.0 +Stomach Contents|0.055328|0.0 +ULI Contents|0.0|0.0 +Rectum|0.0|0.0 +Heart Contents|0.23504|0.0 +Heart Wall|0.0|0.0 +Kidneys|0.3016|0.0 +Liver|1.7368|0.0 +Lungs|0.087024|0.0 +Muscle|0.0|0.0 +Ovaries|0.0|0.0 +Pancreas|0.304928|0.0 +Prostate|0.0|0.0 +Salivary Glands|0.0|0.0 +Red Marrow|0.0|0.0 +Cortical Bone|2.18|0.0 +Trabecular Bone|0.0|0.0 +Spleen|0.11128|0.0 +Testes|0.16016|0.0 +Thymus|0.0|0.0 +Thyroid|0.014248|0.0 +Urinary Bladder Contents|0.0601536|0.0 +Uterus|0.0|0.0 +Fetus|0.0|0.0 +Placenta|0.0|0.0 +Total Body|24.109264|0.0 +[END 25g Mouse SOURCE ORGANS] +[BEGIN 25g Mouse MODEL OPTIONS] +IS_BONE_ACTIVITY_ON_SURFACE|true +[END 25g Mouse MODEL OPTIONS] +[BEGIN 25g Mouse TARGET ORGANS] +Adrenals|0.0 +Brain|0.46592 +Breasts|0.0 +Esophagus|0.0 +Eyes|0.0 +Gallbladder Wall|0.0 +LLI Wall|0.58344 +Small Intestine|1.742 +Stomach|0.055328 +ULI Wall|0.0 +Rectum|0.0 +Heart Wall|0.23504 +Kidneys|0.3016 +Liver|1.7368 +Lungs|0.087024 +Muscle|0.0 +Ovaries|0.0 +Pancreas|0.3049 +Prostate|0.0 +Salivary Glands|0.0 +Red Marrow|0.0 +Bone Surfaces|2.18 +Skin|0.0 +Spleen|0.11128 +Testes|0.16016 +Thymus|0.0 +Thyroid|0.014248 +Urinary Bladder Wall|0.0601536 +Uterus|0.0 +Fetus|0.0 +Placenta|0.0 +Total Body|24.109264 +[END 25g Mouse TARGET ORGANS] +[BEGIN 25g Mouse MODEL TARGET ORGANS] +TARGET_ORGAN_MASSES_ARE_FROM_USER_INPUT|FALSE +[END 25g Mouse MODEL TARGET ORGANS] +[BEGIN KINETIC DATA] +Adrenals|0.0 +Brain|0.0 +Breasts|0.0 +Esophagus|0.0 +Eyes|0.0 +Gallbladder Contents|0.0 +LLI Contents|0.0 +Small Intestine|0.0 +Stomach Contents|0.0 +ULI Contents|0.0 +Rectum|0.0 +Heart Contents|0.0 +Heart Wall|0.0 +Kidneys|0.0 +Liver|0.0 +Lungs|0.0 +Muscle|0.0 +Ovaries|0.0 +Pancreas|0.0 +Prostate|0.0 +Salivary Glands|0.0 +Red Marrow|0.0 +Cortical Bone|0.0 +Trabecular Bone|0.0 +Spleen|0.0 +Testes|0.0 +Thymus|0.0 +Thyroid|0.0 +Urinary Bladder Contents|0.0 +Uterus|0.0 +Fetus|0.0 +Placenta|0.0 +Total Body|0.0 +[END KINETIC DATA] +[END 25g Mouse] +[END MODELS] +[END CASE FILE] diff --git a/pytheranostics/data/output.json b/pytheranostics/data/output.json new file mode 100644 index 0000000..ee54f14 --- /dev/null +++ b/pytheranostics/data/output.json @@ -0,0 +1,132 @@ +{ + "Type": "DosimetryResults", + "InstitutionName": "MyInstitution", + "ClinicalTrial": "MyClinicalTrial", + "Radionuclide": "NA", + "PatientID": "NA", + "Gender": "NA", + + "No_of_completed_cycles": "NA", + "Cycle_01": [ + { + "CycleNumber": 1, + "Operator": "NA", + "DatabaseDir": "NA", + "InjectionDate": "NA", + "InjectionTime": "NA", + "InjectedActivity": "NA", + "Weight_g": "NA", + "Height_cm": "NA", + "Level": "NA", + "Method": "NA", + "OutputFormat": "NA", + "ScaleDoseByDensity": "NA", + "ReferenceTimePoint": "NA", + "TimePoints_h": "NA", + "VOIs": {}, + "Organ-level_AD": {} + } + ], + "Cycle_02": [ + { + "CycleNumber": "NA", + "Operator": "NA", + "DatabaseDir": "NA", + "InjectionDate": "NA", + "InjectionTime": "NA", + "InjectedActivity": "NA", + "Weight_g": "NA", + "Height_cm": "NA", + "Level": "NA", + "Method": "NA", + "OutputFormat": "NA", + "ScaleDoseByDensity": "NA", + "ReferenceTimePoint": "NA", + "TimePoints_h": "NA", + "VOIs": {}, + "Organ-level_AD": {} + } + ], + "Cycle_03": [ + { + "CycleNumber": "NA", + "Operator": "NA", + "DatabaseDir": "NA", + "InjectionDate": "NA", + "InjectionTime": "NA", + "InjectedActivity": "NA", + "Weight_g": "NA", + "Height_cm": "NA", + "Level": "NA", + "Method": "NA", + "OutputFormat": "NA", + "ScaleDoseByDensity": "NA", + "ReferenceTimePoint": "NA", + "TimePoints_h": "NA", + "VOIs": {}, + "Organ-level_AD": {} + } + ], + "Cycle_04": [ + { + "CycleNumber": "NA", + "Operator": "NA", + "DatabaseDir": "NA", + "InjectionDate": "NA", + "InjectionTime": "NA", + "InjectedActivity": "NA", + "Weight_g": "NA", + "Height_cm": "NA", + "Level": "NA", + "Method": "NA", + "OutputFormat": "NA", + "ScaleDoseByDensity": "NA", + "ReferenceTimePoint": "NA", + "TimePoints_h": "NA", + "VOIs": {}, + "Organ-level_AD": {} + } + ], + "Cycle_05": [ + { + "CycleNumber": "NA", + "Operator": "NA", + "DatabaseDir": "NA", + "InjectionDate": "NA", + "InjectionTime": "NA", + "InjectedActivity": "NA", + "Weight_g": "NA", + "Height_cm": "NA", + "ApplyBiokineticsFromPreviousCycle": "NA", + "Level": "NA", + "Method": "NA", + "OutputFormat": "NA", + "ScaleDoseByDensity": "NA", + "ReferenceTimePoint": "NA", + "TimePoints_h": "NA", + "VOIs": {}, + "Organ-level_AD": {} + } + ], + "Cycle_06": [ + { + "CycleNumber": "NA", + "Operator": "NA", + "DatabaseDir": "NA", + "InjectionDate": "NA", + "InjectionTime": "NA", + "InjectedActivity": "NA", + "Weight_g": "NA", + "Height_cm": "NA", + "ApplyBiokineticsFromPreviousCycle": "NA", + "Level": "NA", + "Method": "NA", + "OutputFormat": "NA", + "ScaleDoseByDensity": "NA", + "ReferenceTimePoint": "NA", + "TimePoints_h": "NA", + "VOIs": {}, + "Organ-level_AD": {} + } + ] +} diff --git a/pytheranostics/data/phantom/bone_marrow/Marrow.nii.gz b/pytheranostics/data/phantom/bone_marrow/Marrow.nii.gz new file mode 100644 index 0000000..b17c7c7 Binary files /dev/null and b/pytheranostics/data/phantom/bone_marrow/Marrow.nii.gz differ diff --git a/pytheranostics/data/phantom/human/ICRP_mass_male_source.csv b/pytheranostics/data/phantom/human/ICRP_mass_male_source.csv new file mode 100644 index 0000000..3c71886 --- /dev/null +++ b/pytheranostics/data/phantom/human/ICRP_mass_male_source.csv @@ -0,0 +1,28 @@ +Organ,Mass_g +Adrenals,14 +Brain,1450 +Esophagus,40 +Eyes,15 +Gallbladder Contents,58 +LLI Cont,75 +Small Intestine,350 +Stomach Contents,250 +ULI Cont,150 +Rectum,75 +Heart Contents,510 +Heart Wall,330 +Kidneys,310 +Liver,1800 +Lungs,1200 +Pancreas,140 +Prostate,17 +Salivary Glands,85 +Red Marrow,1170 +Cortical Bone,4400 +Trabecular Bone,1100 +Spleen,150 +Testes,35 +Thymus,25 +Thyroid,20 +Urinary Bladder Contents,211 +Total Body,73000 diff --git a/pytheranostics/data/phantom/human/ICRP_mass_male_target.csv b/pytheranostics/data/phantom/human/ICRP_mass_male_target.csv new file mode 100644 index 0000000..498bc57 --- /dev/null +++ b/pytheranostics/data/phantom/human/ICRP_mass_male_target.csv @@ -0,0 +1,27 @@ +Organ,Mass_g +Adrenals,14 +Brain,1450 +Esophagus,40 +Eyes,15 +Gallbladder Wall,10 +LLI Wall,150 +Small Intestine,650 +Stomach Wall,150 +ULI Wall,150 +Rectum,70 +Heart Wall,330 +Kidneys,310 +Liver,1800 +Lungs,1200 +Pancreas,140 +Prostate,17 +Salivary Glands,85 +Red Marrow,1170 +Cortical Bone,60 +Trabecular Bone,60 +Spleen,150 +Testes,35 +Thymus,25 +Thyroid,20 +Urinary Bladder Wall,50 +Total Body,73000 diff --git a/doodle/dosimetry/phantomdata/PhantomMasses.xlsx b/pytheranostics/data/phantom/human/PhantomMasses.xlsx similarity index 100% rename from doodle/dosimetry/phantomdata/PhantomMasses.xlsx rename to pytheranostics/data/phantom/human/PhantomMasses.xlsx diff --git a/doodle/dosimetry/phantomdata/human_notinphantom_masses.csv b/pytheranostics/data/phantom/human/human_notinphantom_masses.csv similarity index 100% rename from doodle/dosimetry/phantomdata/human_notinphantom_masses.csv rename to pytheranostics/data/phantom/human/human_notinphantom_masses.csv diff --git a/doodle/dosimetry/phantomdata/human_phantom_masses.csv b/pytheranostics/data/phantom/human/human_phantom_masses.csv similarity index 87% rename from doodle/dosimetry/phantomdata/human_phantom_masses.csv rename to pytheranostics/data/phantom/human/human_phantom_masses.csv index bb4996f..538c8c6 100644 --- a/doodle/dosimetry/phantomdata/human_phantom_masses.csv +++ b/pytheranostics/data/phantom/human/human_phantom_masses.csv @@ -27,4 +27,6 @@ Thymus,20,25 Thyroid,17,20 Bladder,40,50 Uterus,80,0 -Body,60000,73000 \ No newline at end of file +Body,60000,73000 +Submandibular Glands,25,25 +Parotid Glands,50,50 diff --git a/pytheranostics/data/phantom/mouse/Organ_weights_mice.xlsx b/pytheranostics/data/phantom/mouse/Organ_weights_mice.xlsx new file mode 100644 index 0000000..931816a Binary files /dev/null and b/pytheranostics/data/phantom/mouse/Organ_weights_mice.xlsx differ diff --git a/pytheranostics/data/phantom/mouse/mouse_notinphantom_masses.csv b/pytheranostics/data/phantom/mouse/mouse_notinphantom_masses.csv new file mode 100644 index 0000000..b1f2f38 --- /dev/null +++ b/pytheranostics/data/phantom/mouse/mouse_notinphantom_masses.csv @@ -0,0 +1,15 @@ +Organ,25g,30g,35g +Adrenals,0.0102,0.0102,0.0102 +Blood,2.4,2.4,2.4 +Fat,6.5,7.8,9.1 +Gallbladder,0.011,0.011,0.011 +Muscle,,, +Ovaries,0.0165,0.0165,0.0165 +Salivary Glands,0.1875,0.1875,0.1875 +Seminals,0.17304533,0.173045,0.173045 +Skin,2.01,2.01,2.01 +Thymus,0.051,0.051,0.051 +Trachea,0.009,0.009,0.009 +Uterus,0.172,0.172,0.172 +Prostate,0.025,, +Red Marrow, 0.608,, diff --git a/pytheranostics/data/phantom/mouse/mouse_phantom_masses.csv b/pytheranostics/data/phantom/mouse/mouse_phantom_masses.csv new file mode 100644 index 0000000..6c18732 --- /dev/null +++ b/pytheranostics/data/phantom/mouse/mouse_phantom_masses.csv @@ -0,0 +1,16 @@ +Organ,25g,30g,35g +Brain,0.466,0.568,0.666 +Heart,0.235,0.291,0.342 +Stomach,0.055,0.069,0.082 +Small Intestine,1.74,2.12,2.49 +Large Intestine,0.583,0.709,0.83 +Kidneys,0.302,0.374,0.432 +Liver,1.74,2.15,2.57 +Lungs,0.087,0.107,0.131 +Pancreas,0.305,0.378,0.45 +Skeleton,2.18,2.61,3.01 +Spleen,0.111,0.136,0.157 +Testes,0.16,0.197,0.228 +Thyroid,0.014,0.016,0.02 +Bladder,0.06,0.075,0.088 +Body,24.11,29.8,35.27 diff --git a/pytheranostics/data/phantom/mouse/rMSF_factor.csv b/pytheranostics/data/phantom/mouse/rMSF_factor.csv new file mode 100644 index 0000000..e457853 --- /dev/null +++ b/pytheranostics/data/phantom/mouse/rMSF_factor.csv @@ -0,0 +1,32 @@ +Organ,25g_mouse,30g_mouse,35g_mouse,Female,Male,rMSF_F_25,rMSF_M_25,rMSF_F_30,rMSF_M_30,rMSF_F_35,rMSF_M_35,,,,,,,,,,,,,,,, +Adrenals,0.0102,0.0102,0.0102,13,14,0.512140523,0.453317217,0.633006536,0.560300833,0.749199346,0.663147999,,,,,,,,,,,,,,,, +Brain,0.466,0.568,0.666,1300,1450,1.120994278,1.027676524,1.136737089,1.042108817,1.147422422,1.051904644,,,,,,,,,,,,,,,, +Gallbladder,0.011,0.011,0.011,8,10,0.292242424,0.300249066,0.361212121,0.371108344,0.427515152,0.439227895,,,,,,,,,,,,,,,, +Left Colon,0.234819444,0.285569444,0.334305556,145,150,0.24813036,0.210975271,0.252186178,0.214423769,0.254963855,0.216785517,,,,,,,,,,,,,,,, +Small Intestine,1.74,2.12,2.49,600,650,0.138563218,0.123378208,0.140566038,0.12516154,0.141646586,0.126123673,,,,,,,,,,,,,,,, +Stomach,0.055,0.069,0.082,140,150,1.022848485,0.900747198,1.007729469,0.887432996,1.003617886,0.883812229,,,,,,,,,,,,,,,, +Right Colon,0.234819444,0.285569444,0.334305556,145,150,0.24813036,0.210975271,0.252186178,0.214423769,0.254963855,0.216785517,,,,,,,,,,,,,,,, +Heart,0.235,0.291,0.342,250,330,0.42748227,0.463788983,0.426689576,0.462928965,0.429702729,0.466198029,,,,,,,,,,,,,,,, +Heart Contents,2.4,2.4,2.4,370,510,0.061949306,0.070183219,0.076569444,0.086746575,0.090624306,0.102669521,,,,,,,,,,,,,,,, +Kidneys,0.302,0.374,0.432,275,310,0.365907837,0.339022952,0.365196078,0.33836349,0.37419946,0.346705353,,,,,,,,,,,,,,,, +Liver,1.74,2.15,2.57,1400,1800,0.323314176,0.34166273,0.323410853,0.341764893,0.320220493,0.338393476,,,,,,,,,,,,,,,, +Lungs,0.087,0.107,0.131,950,1200,4.387835249,4.55550307,4.409657321,4.578159007,4.262913486,4.425807801,,,,,,,,,,,,,,,, +Ovaries,0.0165,0.0165,0.0165,11,0,0.267888889,0,0.331111111,0,0.391888889,0,,,,,,,,,,,,,,,, +Pancreas,0.305,0.378,0.45,120,140,0.158098361,0.151601168,0.157671958,0.151192288,0.156755556,0.150313546,,,,,,,,,,,,,,,, +Prostate,0.025,0.025,0.025,0,17,0,0.224586301,0,0.277589041,0,0.328542466,,,,,,,,,,,,,,,, +Rectum,0.113361111,0.137861111,0.161388889,70,70,0.24813036,0.203942762,0.252186178,0.20727631,0.254963855,0.209559333,,,,,,,,,,,,,,,, +Salivary Glands,0.1875,0.1875,0.1875,70,85,0.150017778,0.149724201,0.185422222,0.185059361,0.219457778,0.219028311,,,,,,,,,,,,,,,, +Red Marrow,0.608,0.608,0.608,900,1170,0.594819079,0.635560112,0.735197368,0.785553353,0.870148026,0.929747206,,,,,,,,,,,,,,,, +Bone Surfaces,2.18,2.61,3.01,120,120,0.022119266,0.018180219,0.022835249,0.018768698,0.023435216,0.019261821,,,,,,,,,,,,,,,, +Spleen,0.111,0.136,0.157,130,150,0.470615616,0.446316179,0.474754902,0.450241741,0.486740977,0.461608935,,,,,,,,,,,,,,,, +Testes,0.16,0.197,0.228,0,35,0,0.072247432,0,0.07252625,0,0.074167868,,,,,,,,,,,,,,,, +Thymus,0.051,0.051,0.051,20,25,0.157581699,0.161899006,0.194771242,0.20010744,0.230522876,0.236838571,,,,,,,,,,,,,,,, +Thyroid,0.014,0.016,0.02,17,20,0.487940476,0.471819961,0.527708333,0.510273973,0.499658333,0.483150685,,,,,,,,,,,,,,,, +Bladder,0.06,0.075,0.088,40,50,0.267888889,0.275228311,0.264888889,0.272146119,0.26719697,0.274517435,,,,,,,,,,,,,,,, +Uterus,0.172,0.172,0.172,80,0,0.186899225,0,0.231007752,0,0.273410853,0,,,,,,,,,,,,,,,, +Body,24.11,29.8,35.27,60000,73000,1,1,1,1,1,1,,,,,,,,,,,,,,,, +Fat,6.5,7.8,9.1,16989,15169.4,1.050268692,0.770778154,1.081778205,0.793902564,1.097439615,0.805396264,,,,,,,,,,,,,,,, +Seminals,0.17304533,0.17304533,0.17304533,0,6.4,0,0.012215027,0,0.015097794,0,0.0178691,,,,,,,,,,,,,,,, +Muscle,3.90675467,6.53475467,9.00875467,17500,29000,1.799980784,2.451637232,1.330067785,1.811599174,1.141898488,1.555305964,,,,,,,,,,,,,,,, +Skin,2.01,2.01,2.01,9600,11680,1.91920398,1.91920398,2.372139303,2.372139303,2.807562189,2.807562189,,,,,,,,,,,,,,,, +Lacrimal,0.001,0.001,0.001,1,1,0.401833333,0.330273973,0.496666667,0.408219178,0.587833333,0.483150685,,,,,,,,,,,,,,,, diff --git a/pytheranostics/data/phantom/skeleton/Skeleton.nii.gz b/pytheranostics/data/phantom/skeleton/Skeleton.nii.gz new file mode 100644 index 0000000..daa0aa3 Binary files /dev/null and b/pytheranostics/data/phantom/skeleton/Skeleton.nii.gz differ diff --git a/pytheranostics/data/radiobiology.json b/pytheranostics/data/radiobiology.json new file mode 100644 index 0000000..412f788 --- /dev/null +++ b/pytheranostics/data/radiobiology.json @@ -0,0 +1,14 @@ +{ + "Kidney_L_a": { + "alpha_beta": 2.6, + "t_repair": 2.8 + }, + "Kidney_R_a": { + "alpha_beta": 2.6, + "t_repair": 2.8 + }, + "Kidneys": { + "alpha_beta": 2.6, + "t_repair": 2.8 + } +} diff --git a/pytheranostics/data/roi_mappings_template.json b/pytheranostics/data/roi_mappings_template.json new file mode 100644 index 0000000..15e5358 --- /dev/null +++ b/pytheranostics/data/roi_mappings_template.json @@ -0,0 +1,49 @@ +{ + "_comment": "Template ROI mapping configuration for PyTheranostics", + "_description": "Copy and customize this file for your project. CT mappings use '_m' suffix (morphology/mass), SPECT mappings use '_a' suffix (activity). Adjust source names to match your RTSTRUCT ROI naming conventions.", + + "ct_mappings": { + "Liver": "Liver", + "Skeleton": "Skeleton", + "Kidney_L_m": "Kidney_Left", + "Kidney_R_m": "Kidney_Right", + "Spleen": "Spleen", + "Bladder": "Bladder", + "Parotid_L_m": "ParotidGland_Left", + "Parotid_R_m": "ParotidGland_Right", + "Submandibular_L_m": "SubmandibularGland_Left", + "Submandibular_R_m": "SubmandibularGland_Right", + "WBCT": "WholeBody", + "WB": "WholeBody", + "BoneMarrow": "BoneMarrow", + "RemainderOfBody": "RemainderOfBody" + }, + + "spect_mappings": { + "Liver": "Liver", + "Skeleton": "Skeleton", + "Kidney_L_a": "Kidney_Left", + "Kidney_R_a": "Kidney_Right", + "Spleen": "Spleen", + "Bladder": "Bladder", + "Parotid_L_a": "ParotidGland_Left", + "Parotid_R_a": "ParotidGland_Right", + "Submandibular_L_a": "SubmandibularGland_Left", + "Submandibular_R_a": "SubmandibularGland_Right", + "WBCT": "WholeBody", + "WB": "WholeBody" + }, + + "_usage_examples": { + "option1_in_notebook": "mappings = LongitudinalStudy.load_mappings_from_json('roi_mappings.json'); result = LongitudinalStudy.apply_per_modality_mappings(ct_study=longCT, spect_study=longSPECT, ct_mask_mapping=mappings['ct_mappings'], spect_mask_mapping=mappings['spect_mappings'])", + "option2_in_loader": "longCT, longSPECT, inj, used = tx.imaging_ds.create_studies_with_masks(storage_root=root, patient_id=pid, cycle_no=1, mapping_config='roi_mappings.json')", + "option3_inline_dict": "result = LongitudinalStudy.apply_per_modality_mappings(ct_study=longCT, spect_study=longSPECT, ct_mask_mapping={'Kidney_L_m': 'Kidney_Left'}, spect_mask_mapping={'Kidney_L_a': 'Kidney_Left'})" + }, + + "_lesion_example": { + "_comment": "For lesions, add entries like below. Use modality-agnostic names or add to both mappings.", + "Lesion1_CT": "Lesion_1", + "Lesion1_SPECT": "Lesion_1", + "Lesion2": "Lesion_2" + } +} diff --git a/pytheranostics/data/s-values/spheres.json b/pytheranostics/data/s-values/spheres.json new file mode 100644 index 0000000..d3f6940 --- /dev/null +++ b/pytheranostics/data/s-values/spheres.json @@ -0,0 +1,22 @@ +{ + "100%/0%": { + "tumor_mass": [4.31E-03, 1.46E-02, 3.45E-02, 1.16E-01, 2.76E-01, 5.39E-01, 9.32E-01, 2.21E+00, 4.31E+00, 1.46E+01, 3.45E+01, 1.16E+02, 2.76E+02, 5.39E+02, 9.32E+02], + "total_s_value": [4.54E-09, 1.44E-09, 6.29E-10, 1.93E-10, 8.26E-11, 4.27E-11, 2.49E-11, 1.06E-11, 5.46E-12, 1.63E-12, 6.93E-13, 2.07E-13, 8.81E-14, 4.54E-14, 2.65E-14] + }, + "75%/25%": { + "tumor_mass": [5.26E-03, 1.77E-02, 4.21E-02, 1.42E-01, 3.36E-01, 6.57E-01, 1.14E+00, 2.69E+00, 5.26E+00, 1.77E+01, 4.21E+01, 1.42E+02, 3.36E+02, 6.57E+02, 1.14E+03], + "total_s_value": [3.86E-09, 1.21E-09, 5.26E-10, 1.60E-10, 6.86E-11, 3.54E-11, 2.06E-11, 8.76E-12, 4.51E-12, 1.35E-12, 5.73E-13, 1.72E-13, 7.32E-14, 3.78E-14, 2.21E-14] + }, + "50%/50%": { + "tumor_mass": [6.20E-03, 2.09E-02, 4.96E-02, 1.67E-01, 3.97E-01, 7.75E-01, 1.34E+00, 3.17E+00, 6.20E+00, 2.09E+01, 4.96E+01, 1.67E+02, 3.97E+02, 7.75E+02, 1.34E+03], + "total_s_value": [3.35E-09, 1.04E-09, 4.51E-10, 1.35E-10, 5.86E-11, 3.02E-11, 1.76E-11, 7.48E-12, 3.85E-12, 1.15E-12, 4.90E-13, 1.47E-13, 6.28E-14, 3.25E-14, 1.90E-14] + }, + "25%/75%": { + "tumor_mass": [7.12E-03, 2.40E-02, 5.70E-02, 1.92E-01, 4.56E-01, 8.90E-01, 1.54E+00, 3.65E+00, 7.12E+00, 2.40E+01, 5.70E+01, 1.92E+02, 4.56E+02, 8.90E+02, 1.54E+03], + "total_s_value": [2.97E-09, 9.18E-10, 3.96E-10, 1.20E-10, 5.11E-11, 2.64E-11, 1.53E-11, 6.52E-12, 3.36E-12, 1.01E-12, 4.28E-13, 1.29E-13, 5.51E-14, 2.86E-14, 1.67E-14] + }, + "0%/100%": { + "tumor_mass": [8.04E-03, 2.71E-02, 6.43E-02, 2.17E-01, 5.15E-01, 1.01E+00, 1.74E+00, 4.12E+00, 8.04E+00, 2.71E+01, 6.43E+01, 2.17E+02, 5.15E+02, 1.01E+03, 1.74E+03], + "total_s_value": [2.66E-09, 8.19E-10, 3.52E-10, 1.06E-10, 4.54E-11, 2.34E-11, 1.36E-11, 5.79E-12, 2.98E-12, 8.94E-13, 3.81E-13, 1.15E-13, 4.91E-14, 2.55E-14, 1.49E-14] + } + } diff --git a/pytheranostics/data/voxel_kernels/Lu177-4.80-mm-mGyperMBqs-SoftICRP.img b/pytheranostics/data/voxel_kernels/Lu177-4.80-mm-mGyperMBqs-SoftICRP.img new file mode 100644 index 0000000..845e503 Binary files /dev/null and b/pytheranostics/data/voxel_kernels/Lu177-4.80-mm-mGyperMBqs-SoftICRP.img differ diff --git a/pytheranostics/dicomtools/__init__.py b/pytheranostics/dicomtools/__init__.py new file mode 100644 index 0000000..1ab4f90 --- /dev/null +++ b/pytheranostics/dicomtools/__init__.py @@ -0,0 +1 @@ +"""DICOM utilities exposed at the package level.""" diff --git a/pytheranostics/dicomtools/dicom_receiver.py b/pytheranostics/dicomtools/dicom_receiver.py new file mode 100644 index 0000000..6f7edb7 --- /dev/null +++ b/pytheranostics/dicomtools/dicom_receiver.py @@ -0,0 +1,869 @@ +""" +DICOM receiver node for PyTheranostics. + +Automatically receives and organizes DICOM images for dosimetry workflows. +""" + +import json +import logging +from datetime import datetime, timedelta +from pathlib import Path +from typing import Callable, Dict, List, Optional + +import pydicom +from pydicom.uid import ( + UID, + DeflatedExplicitVRLittleEndian, + ExplicitVRBigEndian, + ExplicitVRLittleEndian, + ImplicitVRLittleEndian, + RLELossless, +) +from pynetdicom import AE, AllStoragePresentationContexts, evt +from pynetdicom.sop_class import Verification + +# Setup logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class DICOMReceiver: + """ + DICOM C-STORE SCP for receiving and organizing DICOM images for theranostic dosimetry workflows. + + Features: + - Automatic organization by Patient ID, Study, and Modality + - Metadata extraction for dosimetry parameters + - Support for CT, SPECT/NM, PET, and RT Structure Sets + - Configurable storage paths and callbacks + """ + + def __init__( + self, + ae_title: str = "PYTHERANOSTICS", + port: int = 11112, + storage_root: str = "./dicom_data", + structured_storage: bool = True, + allowed_calling_aets: Optional[List[str]] = None, + auto_organize: bool = False, + auto_organize_output_base: Optional[str] = None, + auto_organize_cycle_gap_days: int = 15, + auto_organize_timepoint_separation_days: int = 1, + auto_organize_debounce_seconds: int = 60, + ): + """ + Initialize DICOM receiver. + + Parameters + ---------- + ae_title : str + Application Entity title for this DICOM node + port : int + Port to listen on for incoming DICOM connections + storage_root : str + Root directory for storing received DICOM files + structured_storage : bool + If True, organize files by PatientID/StudyDate/Modality/SeriesNumber + auto_organize : bool + If True, automatically organize received series into Cycle/Timepoint folders after a period of inactivity. + auto_organize_output_base : str | None + Base directory to write organized output. Defaults to storage_root if None. + auto_organize_cycle_gap_days : int + New cycle if consecutive scans are >= this many days apart (default 15). + auto_organize_timepoint_separation_days : int + New timepoint if date changes by this many days (default 1). + auto_organize_debounce_seconds : int + Wait time after the last received file before organizing (per-patient). + """ + self.ae_title = ae_title + self.port = port + self.storage_root = Path(storage_root) + self.structured_storage = structured_storage + self.storage_root.mkdir(parents=True, exist_ok=True) + # Auto organize configuration + self.auto_organize = auto_organize + self.auto_organize_output_base = ( + Path(auto_organize_output_base) + if auto_organize_output_base + else self.storage_root + ) + self.auto_organize_cycle_gap_days = auto_organize_cycle_gap_days + self.auto_organize_timepoint_separation_days = ( + auto_organize_timepoint_separation_days + ) + self.auto_organize_debounce_seconds = auto_organize_debounce_seconds + self._organize_timers = {} + + # Initialize Application Entity + self.ae = AE(ae_title=ae_title) + + # Optionally restrict which Calling AE Titles are accepted + if allowed_calling_aets: + # Exact string match, max 16 chars each + self.ae.require_calling_aet = [ + aet.strip()[:16] for aet in allowed_calling_aets + ] + + # Add supported presentation contexts with a broad set of transfer syntaxes + # Robust set of common transfer syntaxes, using UIDs directly for compatibility + JPEG_BASELINE = UID("1.2.840.10008.1.2.4.50") # JPEG Baseline (Process 1) + JPEG_EXTENDED = UID("1.2.840.10008.1.2.4.51") # JPEG Extended (Process 2 & 4) + JPEG_LOSSLESS_P14 = UID( + "1.2.840.10008.1.2.4.57" + ) # JPEG Lossless, Non-Hierarchical (Process 14) + JPEG_LOSSLESS = UID( + "1.2.840.10008.1.2.4.70" + ) # JPEG Lossless, Non-Hierarchical, First-Order Prediction (Process 14 [Selection Value 1]) + JPEG2000_LOSSLESS = UID( + "1.2.840.10008.1.2.4.90" + ) # JPEG 2000 Image Compression (Lossless Only) + JPEG2000 = UID("1.2.840.10008.1.2.4.91") # JPEG 2000 Image Compression + + transfer_syntaxes = [ + ImplicitVRLittleEndian, + ExplicitVRLittleEndian, + ExplicitVRBigEndian, + DeflatedExplicitVRLittleEndian, + RLELossless, + JPEG_BASELINE, + JPEG_EXTENDED, + JPEG_LOSSLESS_P14, + JPEG_LOSSLESS, + JPEG2000, + JPEG2000_LOSSLESS, + ] + + for cx in AllStoragePresentationContexts: + self.ae.add_supported_context(cx.abstract_syntax, transfer_syntaxes) + + # Support C-ECHO + self.ae.add_supported_context(Verification) + + # Storage for metadata + self.metadata_file = self.storage_root / "received_studies.json" + self.metadata = self._load_metadata() + + # Callbacks + self.on_study_complete_callback: Optional[Callable] = None + + # Internal: schedule organization after inactivity per patient + def _schedule_auto_organize(self, patient_id: str): + if not self.auto_organize: + return + try: + import threading + + # Cancel existing timer if present + t = self._organize_timers.get(patient_id) + if t and t.is_alive(): + t.cancel() + + def _runner(): + try: + logger.info( + f"Auto-organizing cycles for patient {patient_id} after {self.auto_organize_debounce_seconds}s idle" + ) + self.organize_by_cycles( + patient_id=patient_id, + output_base=self.auto_organize_output_base, + cycle_gap_days=self.auto_organize_cycle_gap_days, + timepoint_separation_days=self.auto_organize_timepoint_separation_days, + ) + except Exception as e: + logger.exception(f"Auto-organize failed for {patient_id}: {e}") + + timer = threading.Timer(self.auto_organize_debounce_seconds, _runner) + timer.daemon = True + self._organize_timers[patient_id] = timer + timer.start() + except Exception as e: + logger.exception(f"Failed to schedule auto-organize for {patient_id}: {e}") + + def _load_metadata(self) -> Dict: + """Load existing metadata from a JSON file.""" + if self.metadata_file.exists(): + with open(self.metadata_file, "r") as f: + return json.load(f) + return {} + + def _save_metadata(self): + """Save metadata to a JSON file.""" + with open(self.metadata_file, "w") as f: + json.dump(self.metadata, f, indent=2, default=str) + + def _extract_patient_info(self, ds: pydicom.Dataset) -> Dict: + """ + Extract relevant patient and injection information from a DICOM dataset. + + Parameters + ---------- + ds : pydicom.Dataset + DICOM dataset + + Returns + ------- + dict + Extracted patient information + """ + info = { + "PatientID": getattr(ds, "PatientID", "UNKNOWN"), + "PatientName": str(getattr(ds, "PatientName", "UNKNOWN")), + "PatientWeight": getattr(ds, "PatientWeight", None), # in kg + "StudyDate": getattr(ds, "StudyDate", None), + "StudyTime": getattr(ds, "StudyTime", None), + "StudyDescription": getattr(ds, "StudyDescription", ""), + "SeriesDescription": getattr(ds, "SeriesDescription", ""), + "Modality": getattr(ds, "Modality", "UNKNOWN"), + "SeriesNumber": getattr(ds, "SeriesNumber", 0), + "InstanceNumber": getattr(ds, "InstanceNumber", 0), + } + + # Extract nuclear medicine specific information + if info["Modality"] in ["NM", "PT"]: + info["Radiopharmaceutical"] = None + info["InjectedActivity"] = None + info["InjectionDateTime"] = None + + # Check for RadiopharmaceuticalInformationSequence + if hasattr(ds, "RadiopharmaceuticalInformationSequence"): + rp_seq = ds.RadiopharmaceuticalInformationSequence + if len(rp_seq) > 0: + rp_info = rp_seq[0] + info["Radiopharmaceutical"] = getattr( + rp_info, "Radiopharmaceutical", None + ) + info["InjectedActivity"] = getattr( + rp_info, "RadionuclideTotalDose", None + ) + + # Combine date and time + inj_date = getattr(rp_info, "RadiopharmaceuticalStartDate", None) + inj_time = getattr(rp_info, "RadiopharmaceuticalStartTime", None) + if inj_date and inj_time: + info["InjectionDateTime"] = f"{inj_date} {inj_time}" + + return info + + def _get_storage_path(self, ds: pydicom.Dataset) -> Path: + """ + Determine storage path for a DICOM file. + + Parameters + ---------- + ds : pydicom.Dataset + DICOM dataset + + Returns + ------- + Path + Directory path where file should be stored + """ + if not self.structured_storage: + return self.storage_root + + patient_id = getattr(ds, "PatientID", "UNKNOWN") + study_date = getattr(ds, "StudyDate", "UNKNOWN") + modality = getattr(ds, "Modality", "UNKNOWN") + + # Create structure: PatientID/StudyDate/Modality + path = self.storage_root / patient_id / study_date / modality + + # Special handling for RT Structure Sets + if modality == "RTSTRUCT": + path = self.storage_root / patient_id / study_date / "CT" / "RTstruct" + + path.mkdir(parents=True, exist_ok=True) + return path + + # -------------------------- + # Post-processing utilities + # -------------------------- + @staticmethod + def _parse_dt( + date_str: Optional[str], time_str: Optional[str] + ) -> Optional[datetime]: + """Parse common DICOM date/time fields to a datetime object. + + Parameters + ---------- + date_str : str | None + DICOM DA (YYYYMMDD) + time_str : str | None + DICOM TM (HHMMSS.frac) + + Returns + ------- + datetime | None + Parsed datetime or None if not enough info + """ + if not date_str: + return None + try: + y = int(date_str[0:4]) + m = int(date_str[4:6]) + d = int(date_str[6:8]) + if time_str: + hh = int(time_str[0:2]) if len(time_str) >= 2 else 0 + mm = int(time_str[2:4]) if len(time_str) >= 4 else 0 + ss = int(time_str[4:6]) if len(time_str) >= 6 else 0 + micro = 0 + if len(time_str) > 7 and "." in time_str: + frac = time_str.split(".")[-1] + # pad/cut to microseconds + frac = (frac + "000000")[:6] + micro = int(frac) + return datetime(y, m, d, hh, mm, ss, micro) + return datetime(y, m, d) + except Exception: + return None + + @staticmethod + def _series_datetime_from_any(dcm: pydicom.Dataset) -> Optional[datetime]: + """Best-effort extraction of a datetime for a DICOM series instance. + + Tries SeriesDate/Time, then AcquisitionDate/Time, then ContentDate/Time, + finally falls back to StudyDate/Time. + """ + # Series + dt = DICOMReceiver._parse_dt( + getattr(dcm, "SeriesDate", None), getattr(dcm, "SeriesTime", None) + ) + if dt: + return dt + # Acquisition + dt = DICOMReceiver._parse_dt( + getattr(dcm, "AcquisitionDate", None), getattr(dcm, "AcquisitionTime", None) + ) + if dt: + return dt + # Content + dt = DICOMReceiver._parse_dt( + getattr(dcm, "ContentDate", None), getattr(dcm, "ContentTime", None) + ) + if dt: + return dt + # Study + return DICOMReceiver._parse_dt( + getattr(dcm, "StudyDate", None), getattr(dcm, "StudyTime", None) + ) + + @staticmethod + def _get_any_dicom_datetime_in_path(path: Path) -> Optional[datetime]: + """Find any DICOM file in a directory and return its best-effort datetime. + + Parameters + ---------- + path : Path + Directory containing DICOM files + + Returns + ------- + datetime | None + """ + try: + for dcm_file in sorted(path.glob("*.dcm")): + try: + ds = pydicom.dcmread( + str(dcm_file), stop_before_pixels=True, force=True + ) + dt = DICOMReceiver._series_datetime_from_any(ds) + if dt: + return dt + except Exception: + continue + return None + except Exception: + return None + + def _collect_patient_series(self, patient_id: str) -> List[Dict]: + """Collect all known series for a patient across all studies. + + Returns list of dicts with keys: modality, series_number, series_description, + path (Path), datetime (datetime | None), study_date (str | None). + """ + series_list: List[Dict] = [] + for key, info in self.metadata.items(): + if not key.startswith(f"{patient_id}_"): + continue + study_date = info.get("patient_info", {}).get("StudyDate") + series = info.get("series", {}) + for s_key, s in series.items(): + src_path = Path(s.get("path", self.storage_root)) + # Determine a representative datetime for the series + rep_dt = self._get_any_dicom_datetime_in_path(src_path) + if rep_dt is None and study_date: + # Fallback to study_date + rep_dt = self._parse_dt( + study_date, info.get("patient_info", {}).get("StudyTime") + ) + series_list.append( + { + "modality": s.get("modality", "UNKNOWN"), + "series_number": s.get("series_number", 0), + "series_description": s.get("series_description", ""), + "path": src_path, + "datetime": rep_dt, + "study_date": study_date, + } + ) + # Filter out those without any path + return [x for x in series_list if x.get("path") is not None] + + def organize_by_cycles( + self, + patient_id: str, + output_base: Path, + cycle_gap_days: int = 15, + timepoint_separation_days: int = 1, + ) -> Dict[str, Dict[str, List[Path]]]: + """Post-process received DICOMs into Cycle/Timepoint structure. + + Creates folders like: + PatientID/Cycle1/tp1/CT/Series3 + PatientID/Cycle1/tp1/SPECT/Series5 + PatientID/Cycle1/tp2/CT/Series2 + + RTSTRUCT will be placed under the corresponding CT timepoint: + PatientID/Cycle1/tp1/CT/RTstruct/Series7 + + Parameters + ---------- + patient_id : str + Patient identifier + output_base : Path + Directory under which the new structure will be created + cycle_gap_days : int + Start a new cycle if the gap since the previous scan is >= this many days (default 15 days). + timepoint_separation_days : int + Start a new timepoint when acquisition date changes by this many days or more (default 1 day) + + Returns + ------- + dict + Nested dict with created directories per cycle and timepoint + """ + series_list = self._collect_patient_series(patient_id) + if not series_list: + raise ValueError(f"No series found for patient '{patient_id}'.") + + # Ensure we have datetimes; if some missing, use file mtime as last resort + for s in series_list: + if s["datetime"] is None: + try: + any_file = next(iter(sorted(s["path"].glob("*.dcm")))) + mtime = datetime.fromtimestamp(any_file.stat().st_mtime) + s["datetime"] = mtime + except StopIteration: + # No files present - skip later + s["datetime"] = None + + # Drop any without datetime ultimately + series_list = [s for s in series_list if s["datetime"] is not None] + + # Group series by StudyDate to define timepoints, so RTSTRUCT doesn't create new cycles + # Build mapping: study_date -> list[series] + tp_by_date: Dict[str, List[Dict]] = {} + for s in series_list: + sd = s.get("study_date") or s["datetime"].strftime("%Y%m%d") + tp_by_date.setdefault(sd, []).append(s) + + # Sort timepoints by study date + sorted_dates = sorted(tp_by_date.keys()) + + out: Dict[str, Dict[str, List[Path]]] = {} + patient_root = Path(output_base) / patient_id + patient_root.mkdir(parents=True, exist_ok=True) + + if not sorted_dates: + return out + + # Compute cycles from consecutive study date gaps + cycle_idx = 1 + tp_idx = 1 + prev_date_dt = datetime.strptime(sorted_dates[0], "%Y%m%d") + + for i, sd in enumerate(sorted_dates): + this_date_dt = datetime.strptime(sd, "%Y%m%d") + if i > 0: + if (this_date_dt - prev_date_dt) >= timedelta(days=cycle_gap_days): + # New cycle + cycle_idx += 1 + tp_idx = 1 + else: + # Same cycle, next timepoint (optionally collapse same-day scans if needed) + if ( + this_date_dt.date() - prev_date_dt.date() + ).days >= timepoint_separation_days: + tp_idx += 1 + + # For all series in this study date, place under tp folder + cycle_dir = patient_root / f"Cycle{cycle_idx}" / f"tp{tp_idx}" + cycle_dir.mkdir(parents=True, exist_ok=True) + + # Track source modality directories seen for cleanup after moving + src_dirs_for_cleanup: set[Path] = set() + + for s in tp_by_date[sd]: + modality = s["modality"] + # Normalize modality names for destination + if modality in ["NM", "PT"]: + modality_folder = "SPECT" + elif modality == "RTSTRUCT": + modality_folder = "CT" # RTSTRUCT under CT/RTstruct + else: + modality_folder = modality + + series_number = s.get("series_number", 0) or 0 + # Destination folders drop the Series subfolder; put instances directly under modality + if modality == "RTSTRUCT": + dest_dir = cycle_dir / "CT" / "RTstruct" + else: + dest_dir = cycle_dir / modality_folder + + dest_dir.mkdir(parents=True, exist_ok=True) + + # Copy only files belonging to this SeriesNumber + src_path: Path = s["path"] + src_dirs_for_cleanup.add(src_path) + copied = 0 + for dcm_file in src_path.glob("*.dcm"): + try: + ds = pydicom.dcmread( + str(dcm_file), stop_before_pixels=True, force=True + ) + if int(getattr(ds, "SeriesNumber", -1) or -1) == int( + series_number + ): + import shutil + + dest_file = dest_dir / dcm_file.name + if dest_file.exists(): + # Skip if already present to avoid accidental overwrite + continue + # Move instead of copy to avoid duplication + shutil.move(str(dcm_file), str(dest_file)) + copied += 1 + except Exception: + continue + logger.info( + f"Organized {copied} files -> {dest_dir} ({modality}, Series{int(series_number)}, {sd})" + ) + + # Record in output mapping + cycle_key = f"Cycle{cycle_idx}" + tp_key = f"tp{tp_idx}" + out.setdefault(cycle_key, {}).setdefault(tp_key, []).append(dest_dir) + + # After processing all series for this StudyDate, prune empty source directories + try: + for src_dir in src_dirs_for_cleanup: + # Remove dir if empty + try: + if src_dir.exists() and not any(src_dir.iterdir()): + src_dir.rmdir() + except Exception: + pass + # Attempt to remove parent StudyDate dir if empty + try: + study_parent = src_dir.parent + if study_parent.exists() and not any(study_parent.iterdir()): + study_parent.rmdir() + except Exception: + pass + # Attempt to remove patient dir if now empty (rare) + try: + patient_dir = study_parent.parent + if patient_dir.exists() and not any(patient_dir.iterdir()): + patient_dir.rmdir() + except Exception: + pass + except Exception: + logger.debug("Cleanup after move encountered issues; continuing.") + + prev_date_dt = this_date_dt + + logger.info( + f"Cycle/Timepoint organization complete for patient {patient_id} at {patient_root}" + ) + return out + + def _handle_store(self, event): + """ + Handle an incoming C-STORE request. + + Parameters + ---------- + event : pynetdicom.events.Event + The event corresponding to the C-STORE request + + Returns + ------- + int + DICOM status code (0x0000 for success) + """ + try: + ds = event.dataset + ds.file_meta = event.file_meta + + # Extract information + patient_info = self._extract_patient_info(ds) + storage_path = self._get_storage_path(ds) + + # Generate filename + sop_instance_uid = ds.SOPInstanceUID + filename = storage_path / f"{sop_instance_uid}.dcm" + + # Save DICOM file (avoid deprecation; enforce standard file format) + ds.save_as(filename, enforce_file_format=True) + + logger.info( + f"Received and stored: {patient_info['Modality']} - " + f"Patient {patient_info['PatientID']} - " + f"Series {patient_info['SeriesNumber']}" + ) + + # Update metadata + study_key = f"{patient_info['PatientID']}_{patient_info['StudyDate']}" + if study_key not in self.metadata: + self.metadata[study_key] = { + "patient_info": patient_info, + "series": {}, + "received_date": datetime.now().isoformat(), + } + + series_key = f"{patient_info['Modality']}_{patient_info['SeriesNumber']}" + if series_key not in self.metadata[study_key]["series"]: + self.metadata[study_key]["series"][series_key] = { + "modality": patient_info["Modality"], + "series_number": patient_info["SeriesNumber"], + "series_description": patient_info["SeriesDescription"], + "instance_count": 0, + "path": str(storage_path), + } + + self.metadata[study_key]["series"][series_key]["instance_count"] += 1 + self._save_metadata() + + # Schedule auto-organize for this patient (debounced) + try: + self._schedule_auto_organize(patient_info["PatientID"]) + except Exception as e: + logger.exception(f"Failed to schedule auto-organize: {e}") + + # Return success status + return 0x0000 + except Exception as e: + logger.exception(f"C-STORE processing failed: {e}") + # Processing failure + return 0xC000 + + @staticmethod + def _handle_echo(event): + """Respond to a C-ECHO request with Success.""" + return 0x0000 + + @staticmethod + def _handle_accepted(event): + """Log details when an association is accepted.""" + try: + assoc = event.assoc + calling = assoc.requestor.ae_title.decode("ascii", errors="ignore").strip() + called = assoc.acceptor.ae_title.decode("ascii", errors="ignore").strip() + addr = f"{assoc.requestor.address}:{assoc.requestor.port}" + logger.info( + f"Association accepted: CallingAE='{calling}' -> CalledAE='{called}' from {addr}" + ) + except Exception: + logger.info("Association accepted") + + def start(self, blocking: bool = True): + """ + Start the DICOM receiver. + + Parameters + ---------- + blocking : bool + If True, blocks until server is stopped. If False, runs in background. + """ + handlers = [ + (evt.EVT_C_STORE, self._handle_store), + (evt.EVT_C_ECHO, self._handle_echo), + (evt.EVT_ACCEPTED, self._handle_accepted), + ] + + logger.info(f"Starting DICOM Receiver: {self.ae_title} on port {self.port}") + logger.info(f"Storage location: {self.storage_root.absolute()}") + + if blocking: + self.ae.start_server(("", self.port), evt_handlers=handlers) + else: + import threading + + self.server_thread = threading.Thread( + target=self.ae.start_server, + args=(("", self.port),), + kwargs={"evt_handlers": handlers}, + daemon=True, + ) + self.server_thread.start() + logger.info("DICOM Receiver started in background") + + def stop(self): + """Stop the DICOM receiver.""" + # Cancel any pending auto-organize timers + try: + for t in list(self._organize_timers.values()): + try: + if t and getattr(t, "is_alive", lambda: False)(): + t.cancel() + except Exception: + pass + self._organize_timers.clear() + except Exception: + pass + self.ae.shutdown() + logger.info("DICOM Receiver stopped") + + def get_study_info(self, patient_id: str, study_date: str = None) -> Dict: + """ + Get information about received studies for a patient. + + Parameters + ---------- + patient_id : str + Patient ID + study_date : str, optional + Specific study date (YYYYMMDD format) + + Returns + ------- + dict + Study information + """ + if study_date: + study_key = f"{patient_id}_{study_date}" + return self.metadata.get(study_key, {}) + else: + # Return all studies for this patient + return {k: v for k, v in self.metadata.items() if k.startswith(patient_id)} + + def organize_for_dosimetry( + self, + patient_id: str, + study_date: str, + output_base: Path, + cycle_name: str = "cycle01", + ) -> Dict[str, Path]: + """ + Organize received DICOM files into pytheranostics expected structure. + + Parameters + ---------- + patient_id : str + Patient ID + study_date : str + Study date (YYYYMMDD) + output_base : Path + Base output directory + cycle_name : str + Cycle name (e.g., 'cycle01') + + Returns + ------- + dict + Paths to organized data: {'ct': [...], 'spect': [...], 'rtstruct': [...]} + """ + study_info = self.get_study_info(patient_id, study_date) + if not study_info: + raise ValueError(f"No study found for {patient_id} on {study_date}") + + output_dir = Path(output_base) / patient_id / cycle_name + output_dir.mkdir(parents=True, exist_ok=True) + + organized_paths = {"ct": [], "spect": [], "rtstruct": []} + + # Group series by time point (based on series time or number) + time_point = 1 + + for series_key, series_info in study_info["series"].items(): + modality = series_info["modality"] + source_path = Path(series_info["path"]) + + if modality == "CT": + dest_path = output_dir / f"tp{time_point}" / "CT" + dest_path.mkdir(parents=True, exist_ok=True) + organized_paths["ct"].append(dest_path) + + # Copy files + for dcm_file in source_path.glob("*.dcm"): + import shutil + + shutil.copy2(dcm_file, dest_path / dcm_file.name) + + elif modality in ["NM", "PT"]: + dest_path = output_dir / f"tp{time_point}" / "SPECT" + dest_path.mkdir(parents=True, exist_ok=True) + organized_paths["spect"].append(dest_path) + + # Copy files + for dcm_file in source_path.glob("*.dcm"): + import shutil + + shutil.copy2(dcm_file, dest_path / dcm_file.name) + + elif modality == "RTSTRUCT": + dest_path = output_dir / f"tp{time_point}" / "CT" / "RTstruct" + dest_path.mkdir(parents=True, exist_ok=True) + organized_paths["rtstruct"].append(dest_path) + + # Copy files + for dcm_file in source_path.glob("*.dcm"): + import shutil + + shutil.copy2(dcm_file, dest_path / dcm_file.name) + + logger.info(f"Organized data for {patient_id} into {output_dir}") + return organized_paths + + +def create_receiver( + ae_title: str = "PYTHERANOSTICS", + port: int = 11112, + storage_root: str = "./dicom_data", + allowed_calling_aets: Optional[List[str]] = None, + auto_organize: bool = False, + auto_organize_output_base: Optional[str] = None, + auto_organize_cycle_gap_days: int = 15, + auto_organize_timepoint_separation_days: int = 1, + auto_organize_debounce_seconds: int = 60, +) -> DICOMReceiver: + """ + Create a DICOM receiver. + + Parameters + ---------- + ae_title : str + Application Entity title + port : int + Port number + storage_root : str + Root directory for storage + allowed_calling_aets : list[str] | None + Optional list of allowed Calling AE Titles (whitelist). If None, accept any. + + Returns + ------- + DICOMReceiver + Configured DICOM receiver instance + """ + return DICOMReceiver( + ae_title=ae_title, + port=port, + storage_root=storage_root, + allowed_calling_aets=allowed_calling_aets, + auto_organize=auto_organize, + auto_organize_output_base=auto_organize_output_base, + auto_organize_cycle_gap_days=auto_organize_cycle_gap_days, + auto_organize_timepoint_separation_days=auto_organize_timepoint_separation_days, + auto_organize_debounce_seconds=auto_organize_debounce_seconds, + ) diff --git a/pytheranostics/dicomtools/dicomtools.py b/pytheranostics/dicomtools/dicomtools.py new file mode 100644 index 0000000..209d5c9 --- /dev/null +++ b/pytheranostics/dicomtools/dicomtools.py @@ -0,0 +1,347 @@ +"""Utility functions for reading and modifying nuclear medicine DICOM files.""" + +import time +from datetime import datetime +from pathlib import Path +from typing import Any, List, Tuple + +import numpy as np +import pandas as pd +import pydicom +import SimpleITK +from pydicom.dataset import Dataset +from pydicom.uid import generate_uid + +from pytheranostics.shared.radioactive_decay import get_activity_at_injection + + +class DicomModify: + """Helper that edits DICOM headers/pixel data for quantitative SPECT studies.""" + + def __init__(self, fname, CF): + """Load the DICOM file and store calibration info.""" + self.ds = pydicom.dcmread(fname) + self.CF = CF + self.fname = fname + + def make_bqml_suv( + self, + weight, + height, + injection_date, + pre_inj_activity, + pre_inj_time, + post_inj_activity, + post_inj_time, + injection_time, + activity_meter_scale_factor, + half_life=574300, + radiopharmaceutical="Lutetium-PSMA-617", + n_detectors=2, + ): + """Convert raw counts to BQML/SUV units and update the header accordingly.""" + # Half-life is in seconds + + # Siemens has an issue setting up the times. We are using the Acquisition time which is the time of the start of the last bed to harmonize. + if "siemens" in self.ds.Manufacturer.lower(): + self.ds.SeriesTime = self.ds.AcquisitionTime + self.ds.ContentTime = self.ds.AcquisitionTime + elif "ge" in self.ds.Manufacturer.lower(): # i think it applies to ge as well + self.ds.SeriesTime = self.ds.AcquisitionTime + self.ds.ContentTime = self.ds.AcquisitionTime + + # Get the frame duration in seconds + frame_duration = ( + self.ds.RotationInformationSequence[0].ActualFrameDuration / 1000 + ) + # get number of projections because manufacturers scale by this in the dicomfile + if "siemens" in self.ds.Manufacturer.lower(): + n_proj = ( + self.ds.RotationInformationSequence[0].NumberOfFramesInRotation + * n_detectors + ) + elif "ge" in self.ds.Manufacturer.lower(): + n_proj = self.ds.RotationInformationSequence[0].NumberOfFramesInRotation + # get voxel volume in ml + vox_vol = np.append( + np.asarray(self.ds.PixelSpacing), float(self.ds.SliceThickness) + ) + vox_vol = np.prod(vox_vol / 10) + + # Get image in Bq/ml + A = self.ds.pixel_array + A.astype(np.float64) + A = A / (frame_duration * n_proj) * self.CF * 1e6 / vox_vol + + slope, intercept = dicom_slope_intercept(A) + + # update the PixelData + A = np.int16((A - intercept) / slope) # GE dicom is signed so np.int16 + + # bring the new image to the pixel bytes + self.ds.PixelData = A.tobytes() + + self.ds.PixelData + + # update DICOM tags + # self.ds.Units = 'BQML' + self.ds.SeriesDescription = "QSPECT_" + self.ds.SeriesDescription + + # add the RealWorldValueMappingSequence tag [0040,9096] + self.ds.add_new([0x0040, 0x9096], "SQ", []) + self.ds.RealWorldValueMappingSequence += [Dataset(), Dataset()] + + for i in range(2): + self.ds.RealWorldValueMappingSequence[i].RealWorldValueIntercept = intercept + self.ds.RealWorldValueMappingSequence[i].RealWorldValueSlope = slope + self.ds.RealWorldValueMappingSequence[i].RealWorldValueLastValueMapped = ( + int(A.max()) + ) + self.ds.RealWorldValueMappingSequence[i].RealWorldValueFirstValueMapped = ( + int(A.min()) + ) + + self.ds.RealWorldValueMappingSequence[i].LUTLabel = "BQML" + self.ds.RealWorldValueMappingSequence[i].add_new([0x0040, 0x08EA], "SQ", []) + self.ds.RealWorldValueMappingSequence[i].MeasurementUnitsCodeSequence += [ + Dataset() + ] + self.ds.RealWorldValueMappingSequence[i].MeasurementUnitsCodeSequence[ + 0 + ].CodeValue = "Bq/ml" + + # add info for SUV + self.ds.PatientWeight = str(weight) # in kg + self.ds.PatientSize = str(height / 100) # in m + + self.ds.DecayCorrection = "START" + self.ds.CorrectedImage.insert(0, "DECY") + + self.ds.add_new([0x0054, 0x0016], "SQ", []) + self.ds.RadiopharmaceuticalInformationSequence += [Dataset()] + + # values for net injected activity and injection date and time + start_datetime, total_injected_activity = get_activity_at_injection( + injection_date, + pre_inj_activity, + pre_inj_time, + post_inj_activity, + post_inj_time, + injection_time, + half_life=half_life, + ) + total_injected_activity = total_injected_activity * activity_meter_scale_factor + + scan_datetime = datetime.strptime( + self.ds.SeriesDate + self.ds.SeriesTime, "%Y%m%d%H%M%S.%f" + ) + delta_scan_inj = (scan_datetime - start_datetime).total_seconds() / ( + 60 * 60 * 24 + ) + + pre_inj_datetime = datetime.strptime( + injection_date + pre_inj_time, "%Y%m%d%H%M" + ) + post_inj_datetime = datetime.strptime( + injection_date + post_inj_time, "%Y%m%d%H%M" + ) + + inj_dic = { + "patient_id": [self.ds.PatientID], + "weight_kg": [weight], + "height_m": [height], + "pre_inj_activity_MBq": [pre_inj_activity], + "pre_inj_datetime": [pre_inj_datetime], + "post_inj_activity_MBq": [post_inj_activity], + "post_inj_datetime": [post_inj_datetime], + "injected_activity_MBq": [total_injected_activity], + "injection_datetime": [start_datetime], + "scan_datetime": [scan_datetime], + "delta_t_days": [delta_scan_inj], + } + inj_df = pd.DataFrame(data=inj_dic) + + self.ds.RadiopharmaceuticalInformationSequence[0].Radiopharmaceutical = ( + radiopharmaceutical + ) + self.ds.RadiopharmaceuticalInformationSequence[0].RadiopharmaceuticalVolume = "" + self.ds.RadiopharmaceuticalInformationSequence[ + 0 + ].RadiopharmaceuticalStartTime = start_datetime.strftime("%H%M%S.%f") + self.ds.RadiopharmaceuticalInformationSequence[0].RadionuclideTotalDose = str( + round(total_injected_activity, 4) + ) + self.ds.RadiopharmaceuticalInformationSequence[0].RadionuclideHalfLife = str( + half_life + ) + self.ds.RadiopharmaceuticalInformationSequence[ + 0 + ].RadionuclidePositronFraction = "" + self.ds.RadiopharmaceuticalInformationSequence[ + 0 + ].RadiopharmaceuticalStartDateTime = start_datetime.strftime("%Y%m%d%H%M%S.%f") + + # for storing as new series data + sop_ins_uid = self.ds.SOPInstanceUID + b = sop_ins_uid.split(".") + b[-1] = str(int(b[-1]) + 1) + self.ds.SOPInstanceUID = ".".join(b) + + ser_ins_uid = self.ds.SeriesInstanceUID + b = ser_ins_uid.split(".") + b.pop() + prefix = ".".join(b) + "." + self.ds.SeriesInstanceUID = generate_uid(prefix=prefix) + + # self.ds.MediaStorageSOPInstaceUID + return inj_df + + def save(self): + """Persist the modified dataset alongside the original file.""" + self.ds.save_as(f"{self.fname.split('.dcm')[0]}_out.dcm") + + +def dicom_slope_intercept(img): + """Calculate GE-style slope/intercept for converting floats to signed int16. + + GE PET images are stored as signed int16 values with magnitude limited to + 32767. The computed slope ensures the largest absolute voxel value in the + floating-point array (e.g., MBq/mL) maps to this range once quantized, while + the intercept remains zero (GE convention). + """ + max_val = np.max(img) + min_val = np.min(img) + + slope = np.float32(max(max_val, -min_val) / 32767) + intercept = 0 # GE has assigned it to zero + + return float(slope), float(intercept) + + +def generate_basic_dcm_tags( + img_size: Tuple[int, int, int], + slice_thickness: float, + name: str, + description: str, + direction: Tuple[float, ...], + date: str, + time: str, +) -> List[Any]: + """Generate the minimal tag set needed for a synthetic DICOM series.""" + series_tag_values = [ + ("0008|0031", time), # Series Time + ("0008|0021", date), # Series Date + ("0008|0008", "DERIVED\\SECONDARY"), # Image Type + ( + "0020|000e", + "1.2.826.0.1.3680043.2.1125." + date + ".1" + time, + ), # Series Instance UID + ( + "0020|0037", + "\\".join( + map( + str, + ( + direction[0], + direction[3], + direction[6], # Image Orientation (Patient) + direction[1], + direction[4], + direction[7], + ), + ) + ), + ), + ("0008|103e", description), # Series Description + # patient information + ("0010|0010", name), # Patient name + ("0010|0020", name), # Patient ID + # image space information + ("0028|0010", str(img_size[1])), # rows + ("0028|0011", str(img_size[2])), # columns + ("0018|0050", f"{slice_thickness: 1.3f}"), # slice thickness + ("0054|0081", str(img_size[0])), # number of slices + ] + + return series_tag_values + + +def numpy_to_dcm_basic( + array: np.ndarray, + voxel_spacing: Tuple[float, float, float], + output_dir: Path, + patien_name: str = "Patient", + scale: int = 1, +) -> None: + """Write a NumPy array as a basic DICOM series for visualization/testing. + + Notes + ----- + Adapted from: R. Fedrigo et al., "Development of the Lymphatic System in the + 4D XCAT Phantom for Improved Multimodality Imaging Research," J. Nucl. Med., + 62, 113 (2021). + """ + # Create SimpleITK image from array + array = array * scale + sitk_image = SimpleITK.GetImageFromArray(array.astype(np.int16)) + sitk_image.SetSpacing(voxel_spacing) + + # Create output Folder + output_dir.mkdir(exist_ok=True, parents=True) + + # Write the 3D image as a DCM Series + writer = SimpleITK.ImageFileWriter() + writer.KeepOriginalImageUIDOn() + modification_time = time.strftime("%H%M%S") + modification_date = time.strftime("%Y%m%d") + direction = sitk_image.GetDirection() + + tag_values = generate_basic_dcm_tags( + img_size=array.shape, + slice_thickness=voxel_spacing[0], + name=patien_name, + description=patien_name, + direction=direction, + date=modification_date, + time=modification_time, + ) + # Loop through slices + for i in range(sitk_image.GetDepth()): + image_slice = sitk_image[:, :, i] + + # Tags shared by the series. + for tag, value in tag_values: + image_slice.SetMetaData(tag, value) + + # Slice specific tags. + image_slice.SetMetaData( + "0008|0012", time.strftime("%Y%m%d") + ) # Instance Creation Date + image_slice.SetMetaData( + "0008|0013", time.strftime("%H%M%S") + ) # Instance Creation Time + + # Setting the type to CT preserves the slice location. + image_slice.SetMetaData("0008|0060", "CT") # set the type as a PET image + + # (0020, 0032) image position patient determines the 3D spacing between slices. + image_slice.SetMetaData( + "0020|0032", + "\\".join(map(str, sitk_image.TransformIndexToPhysicalPoint((0, 0, i)))), + ) # Image Position (Patient) + image_slice.SetMetaData("0020,0013", str(i)) # Instance Number + + # Write to the output directory and add the extension dcm, to force writing in DICOM format. + writer.SetFileName(str(output_dir / f"{i}.dcm")) + writer.Execute(image_slice) + + return None + + +def sitk_load_dcm_series(dcm_dir: Path) -> SimpleITK.Image: + """Load a DICOM series using SimpleITK and return it as an image volume.""" + reader = SimpleITK.ImageSeriesReader() + dcm_file_names = reader.GetGDCMSeriesFileNames(str(dcm_dir)) + reader.SetFileNames(dcm_file_names) + + return reader.Execute() diff --git a/pytheranostics/dosimetry/__init__.py b/pytheranostics/dosimetry/__init__.py new file mode 100644 index 0000000..9dfc220 --- /dev/null +++ b/pytheranostics/dosimetry/__init__.py @@ -0,0 +1,11 @@ +"""Dosimetry package. + +PEP 8 compliant package with lowercase module names. +""" + +__all__ = [ + "base_dosimetry", + "organ_s_dosimetry", + "voxel_s_dosimetry", + "bone_marrow", +] diff --git a/pytheranostics/dosimetry/base_dosimetry.py b/pytheranostics/dosimetry/base_dosimetry.py new file mode 100644 index 0000000..4e367d3 --- /dev/null +++ b/pytheranostics/dosimetry/base_dosimetry.py @@ -0,0 +1,1021 @@ +"""Base dosimetry module for radiation dose calculations.""" + +import abc +import json +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import lmfit +import numpy +import pandas + +from pytheranostics.dosimetry.bone_marrow import bm_scaling_factor +from pytheranostics.fits.fits import exponential_fit_lmfit +from pytheranostics.imaging_ds.longitudinal_study import LongitudinalStudy +from pytheranostics.imaging_tools.tools import extract_masks +from pytheranostics.misc_tools.tools import calculate_time_difference +from pytheranostics.plots.plots import plot_tac_residuals +from pytheranostics.shared.resources import resource_path + + +class BaseDosimetry(metaclass=abc.ABCMeta): + """Base class for performing organ-level patient-specific dosimetry. + + This class provides the foundation for computing organ time-integrated activity curves + and leveraging organ-level S-values for dosimetry calculations. It handles both Nuclear + Medicine and CT data to perform comprehensive dosimetry analysis. + + Parameters + ---------- + nm_data : LongitudinalStudy + Nuclear Medicine data containing time series of images and masks. + ct_data : LongitudinalStudy + CT data containing anatomical information and masks. + config : dict + Configuration dictionary containing dosimetry parameters and settings. + + Attributes + ---------- + nm_data : LongitudinalStudy + Nuclear Medicine data instance. + ct_data : LongitudinalStudy + CT data instance. + config : dict + Configuration parameters. + results : pandas.DataFrame + DataFrame containing dosimetry results. + db_dir : Path + Directory for storing dosimetry results. + + Notes + ----- + This is an abstract base class that should be subclassed to implement specific + dosimetry calculation methods. + """ + + def __init__( + self, + config: Dict[str, Any], + nm_data: LongitudinalStudy, + ct_data: LongitudinalStudy, + clinical_data: Optional[pandas.DataFrame] = None, + ) -> None: + """Initialize the base dosimetry class. + + Parameters + ---------- + patient_id : str + Patient ID. + cycle : int + The cycle number (1, 2, ...). + config : Dict + Configuration parameters for dosimetry calculations. + database_dir : str + A folder to store patient-dosimetry results. + nm_data : LongitudinalStudy + Longitudinal, quantitative, nuclear-medicine imaging data. + Note: voxel values should be in units of Bq/mL. + ct_data : LongitudinalStudy + Longitudinal CT imaging data. + Note: voxel values should be in HU units. + clinical_data : pandas.DataFrame, optional + Clinical data such as blood sampling. + Note: blood counting should be in units of Bq/mL. + """ + # Configuration + self.config = config + self.toMBq = 1e-6 # Factor to scale activity from Bq to MBq + + # Store data + self.patient_id = config["PatientID"] + self.cycle = config["Cycle"] + self.db_dir = Path(config["DatabaseDir"]) + self.check_mandatory_fields() + self.check_patient_in_db() # TODO: Traceability/database? + + self.nm_data = nm_data + self.nm_data.check_masks_consistency() + + self.ct_data = ct_data + self.ct_data.check_masks_consistency() + + self.clinical_data = clinical_data + + with resource_path( + "pytheranostics.data", "s-values/spheres.json" + ) as spheres_path: + with spheres_path.open("r", encoding="utf-8") as file: + self.mass_and_s_values = json.load(file) + + if ( + self.clinical_data is not None + and self.clinical_data["PatientID"].unique()[0] != self.patient_id + ): + raise AssertionError( + "Clinical Data does not correspond to patient specified by user." + ) + + # Verify radionuclide information is present in nm_data. + self.radionuclide = self.check_nm_data() + + # Extract ROIs from user-specified list, and ensure there are no overlaps. + self.extract_masks_and_correct_overlaps() + + # DataFrame storing results + self.results = self.initialize() + self.results_dosimetry_lesions = pandas.DataFrame() + self.results_dosimetry_salivaryglands = pandas.DataFrame() + self.results_dosimetry_organs = pandas.DataFrame() + + # Sanity Checks: + self.sanity_checks(metric="Volume_CT_mL") + self.sanity_checks(metric="Activity_MBq") + + # Handle default values, if missing in config: + self.default_config() + + # Dose Maps: use LongitudinalStudy Data Structure to store dose maps and leverage built-in operations. + self.dose_map: LongitudinalStudy = LongitudinalStudy( + images={}, meta={}, modality="DOSE" + ) # Initialize to empty study. + + def default_config(self) -> None: + """Set to None/False the mandatory keys in the config dictionary if not defined. + + We could achieve the same behaviour with dict.get(key, None) but this way we + inform the user. + """ + defaults = { + "fixed_parameters": None, + "param_init": None, + "with_uptake": False, + "fit_order": 1, + "bounds": None, + "washout_ratio": None, + } + + for key, value in defaults.items(): + for region, _ in self.results.iterrows(): + if key not in self.config["VOIs"][region]: + self.config["VOIs"][region][key] = value + print( + f"For {region}, the parameter '{key}' was not defined by the user, set to {value}." + ) + + def extract_masks_and_correct_overlaps(self) -> None: + """Extract masks and correct overlaps between regions.""" + # Inform the user if some masks are unused and therefore excluded. + for roi_name in self.nm_data.masks[0]: + if roi_name not in self.config["VOIs"] and roi_name != "BoneMarrow": + print( + f"Although mask for {roi_name} is present, we are ignoring it because this region was not included in the" + " configuration input file.\n" + ) + continue + + self.nm_data.masks = { + time_id: extract_masks( + time_id=time_id, + mask_dataset=self.nm_data.masks, + requested_rois=list(self.config["VOIs"].keys()), + ) + for time_id in self.nm_data.masks.keys() + } + + self.ct_data.masks = { + time_id: extract_masks( + time_id=time_id, + mask_dataset=self.ct_data.masks, + requested_rois=list(self.config["VOIs"].keys()), + ) + for time_id in self.ct_data.masks.keys() + } + + # Check availability of requested rois in existing masks + for roi_name in self.config["VOIs"]: + if roi_name not in self.nm_data.masks[0] and roi_name != "BoneMarrow": + raise AssertionError(f"The following mask was NOT found: {roi_name}\n") + + # Verify that masks in NM and CT data are consistent (i.e., there is a mask for each region in both domains): + self.check_nm_ct_masks() + + return None + + def check_nm_ct_masks(self) -> None: + """Check that, for each time point, each region contains masks in both NM and CT datasets.""" + for time_id, nm_masks in self.nm_data.masks.items(): + nm_masks_list = list(nm_masks.keys()) + ct_masks_list = list(self.ct_data.masks[time_id].keys()) + + if sorted(nm_masks_list) != sorted(ct_masks_list): + raise AssertionError( + f"Found inconsistent masks at Time ID: {time_id}: \n" + f"NM: {sorted(nm_masks_list)} \n" + f"CT: {sorted(ct_masks_list)}" + ) + + return None + + def check_mandatory_fields(self) -> None: + """Check for required fields in the configuration. + + Raises + ------ + ValueError + If required fields are missing from configuration. + """ + if "InjectionDate" not in self.config or "InjectionTime" not in self.config: + raise ValueError("Incomplete Configuration file.") + + if "ReferenceTimePoint" not in self.config: + print("No Reference Time point was given. Assigning time ID = 0") + self.config["ReferenceTimePoint"] = 0 + + if "Organ" in self.config["Level"]: + if "WholeBody" not in self.config["VOIs"]: + if "No" in self.config["OrganLevel"]["AdditionalOptions"]["WholeBody"]: + pass + else: + raise ValueError("Missing 'WholeBody' region parameters.") + + if "RemainderOfBody" not in self.config["VOIs"]: + if ( + "No" + in self.config["OrganLevel"]["AdditionalOptions"]["RemainderOfBody"] + ): + pass + else: + raise ValueError("Missing 'RemainderOfBody' region parameters.") + + return None + + def initialize(self) -> pandas.DataFrame: + """Populate initial result dataframe containing organs of interest, volumes, acquisition times, etc.""" + tmp_results: Dict[str, List[float]] = { + roi_name: [] + for roi_name in self.nm_data.masks[0].keys() + if roi_name in self.config["VOIs"] + } + + cols: List[str] = ["Time_hr", "Volume_CT_mL", "Activity_MBq", "Density_HU"] + time_ids = [time_id for time_id in self.nm_data.masks.keys()] + + # Normalize Acquisition Times, relative to time of injection + for time_id in self.nm_data.meta.keys(): + self.normalize_time_to_injection(time_id=time_id) + + for roi_name in tmp_results.keys(): + + # Time (relative to time of injection, in hours) + tmp_results[roi_name].append( + [self.nm_data.meta[time_id].HoursAfterInjection for time_id in time_ids] + ) + + # Volume (from CT, in mL) + tmp_results[roi_name].append( + [ + self.ct_data.volume_of(region=roi_name, time_id=time_id) + for time_id in time_ids + ] + ) + + # Activity, in MBq + tmp_results[roi_name].append( + [ + self.nm_data.activity_in(region=roi_name, time_id=time_id) + * self.toMBq + for time_id in time_ids + ] + ) + # Density (from CT, in HU) + tmp_results[roi_name].append( + [ + self.ct_data.density_of(region=roi_name, time_id=time_id) + for time_id in time_ids + ] + ) + + return pandas.DataFrame.from_dict( + self.initialize_bone_marrow(tmp_results), orient="index", columns=cols + ) + + def initialize_bone_marrow( + self, temp_results: Dict[str, List[float]] + ) -> Dict[str, List[float]]: + """Initialize activity and times for Bone-Marrow blood-based measurements.""" + if ( + "BoneMarrow" in self.config["VOIs"] + and self.clinical_data is not None + and "BoneMarrow" not in self.nm_data.masks[0] + ): + + # Computing blood-based method -> Scale activity concentration in blood + # to activity in Bone-Marrow, using ICRP phantom mass and haematocrit. + scaling_factor = bm_scaling_factor( + gender=self.config["Gender"], + hematocrit=self.clinical_data["Haematocrit"].unique()[0], + ) + + temp_results["BoneMarrow"] = [ + self.clinical_data["Time_hr"].to_list(), + self.clinical_data["Volume_mL"].to_list(), + [ + act * scaling_factor * self.toMBq + for act in self.clinical_data["Activity_Bq"].to_list() + ], + ] + + return temp_results + + def check_nm_data(self) -> Dict[str, Any]: + """Verify that radionuclide info is present in NM data. + + Also verify that radionuclide data (e.g., half-life) is available in internal database. + """ + # Load Radionuclide data + with resource_path("pytheranostics.data", "isotopes.json") as rad_data_path: + with rad_data_path.open("r", encoding="utf-8") as rad_data: + radionuclide_data = json.load(rad_data) + + if self.nm_data.meta[0].Radionuclide is None: + raise ValueError("Nuclear Medicine Data missing radionuclide") + + if self.nm_data.meta[0].Radionuclide not in radionuclide_data: + raise ValueError( + f"Data for {self.nm_data.meta[0].Radionuclide} is not available." + ) + + return radionuclide_data[self.nm_data.meta[0].Radionuclide] + + def check_patient_in_db(self) -> None: + """Check if prior dosimetry exists for this patient.""" + # TODO: handle logging: error/warnings/prints. + print( + "Database search function not implemented. Dosimetry for this patient might " + "already exists..." + ) + + self.db_dir.mkdir(parents=True, exist_ok=True) + + return None + + def sanity_checks(self, metric: str) -> None: + """Check that metric in wholebody is equal to sum of metric in individual regions. + + Note: currently excluding BoneMarrow. + + Args + ---- + metric (str): The metric to check. + """ + if ( + "BoneMarrow" in self.results.index + and "BoneMarrow" not in self.nm_data.masks[0].keys() + ): + tmp_results = self.results.drop("BoneMarrow", axis=0) + else: + tmp_results = self.results.copy() + + # TODO: add assertions, run it silently. + print(" ------------------------------- ") + print(f"Running Sanity Checks on: {metric}") + metric_data = tmp_results[metric].to_list() + times = tmp_results["Time_hr"].to_list() + + for time_id in range(len(metric_data[-1])): + whole_metric = metric_data[-1][time_id] + sum_metric = sum([vol[time_id] for vol in metric_data[:-1]]) + print(f"At T = {times[0][time_id]:2.2f} hours:") + print(f" >>> WholeBody {metric} = {whole_metric: 2.2f}") + print(f" >>> Regions {metric} = {sum_metric: 2.2f}") + print( + f" >>> % Difference = {(whole_metric - sum_metric) / whole_metric * 100:2.2f}" + ) + print(" ") + + return None + + def compute_tia(self) -> None: + """Compute Time-Integrated Activity over each source-organ.""" + if self.radionuclide["half_life_units"] != "hours": + raise AssertionError( + "Radionuclide Half-Life in Database should be in hours." + ) + + tmp_tia_data = { + "Fit_params": [], + "R_squared_AIC": [], + "TIA_MBq_h": [], + "TIA_h": [], + "Lambda_eff": [], + } + + for region, region_data in self.results.iterrows(): + fit_results = self.smart_fit_selection( + region_data=region_data, region=region + ) + + plot_tac_residuals( + result=fit_results, + region=region, + cycle=self.config["Cycle"], + output_dir=self.db_dir, + ) + + # Parameters for sum of exponential functions: + fit_params = [ + fit_results.params[param].value for param in fit_results.params.keys() + ] # A1, B1, A2, B2, ... + print(fit_results.fit_report()) + + # CHECK BOUNDS PHYSICAL DECAY + # Fitting Parameters ## TODO: Implement functions from Glatting paper so that unknown parameter is only biological half-life + tmp_tia_data["Fit_params"].append(fit_params) + + # R_Squared and Akaike Information Criterion + try: + tmp_tia_data["R_squared_AIC"].append( + [fit_results.rsquared, fit_results.aic] + ) + except AttributeError: + tmp_tia_data["R_squared_AIC"].append([numpy.nan, numpy.nan]) + + # Calculate Integral: + tmp_tia_data["TIA_MBq_h"].append( + self.analytical_integrate(result=fit_results) + ) + + # Lambda effective Olny informative for mono-exponential. + exp_params = [1, 3, 5] + tmp_tia_data["Lambda_eff"].append( + [ + fit_params[exp_params[i]] + for i in range(self.config["VOIs"][region]["fit_order"]) + ] + ) + + # Residence Time + tmp_tia_data["TIA_h"].append( + tmp_tia_data["TIA_MBq_h"][-1] / (float(self.config["InjectedActivity"])) + ) + + for key, values in tmp_tia_data.items(): + self.results.loc[:, key] = values + + return None + + def smart_fit_selection( + self, region_data: pandas.Series, region: str + ) -> lmfit.model.ModelResult: + """Select the best fit based on Akaike Information Criterion.""" + # If fit_order is defined by user: + if self.config["VOIs"][region]["fit_order"] is not None: + print(region) + fit_results, _ = exponential_fit_lmfit( + x_data=numpy.array(region_data["Time_hr"]), + y_data=numpy.array(region_data["Activity_MBq"]), + fixed_params=self.config["VOIs"][region]["fixed_parameters"], + num_exponentials=self.config["VOIs"][region]["fit_order"], + bounds=self.config["VOIs"][region]["bounds"], + params_init=self.config["VOIs"][region]["param_init"], + with_uptake=self.config["VOIs"][region]["with_uptake"], + washout_ratio=self.config["VOIs"][region]["washout_ratio"], + ) + + return fit_results + + print( + f"WARNING: 'fit_order' for {region} was not specified, finding the best fit from Akaike Information Criteria..." + ) + + # Determine maximum fit order based on avialable data. + n_samples = numpy.array(region_data["Time_hr"]).shape[0] + activity_init = region_data["Activity_MBq"][0] + + max_order = min(n_samples // 2, 3) # Don't use more than tri-exponential. + + all_fits: List[lmfit.model.ModelResult] = [] + fit_config: List[Tuple[bool, int]] = [] + + for order in range(1, max_order + 1): + for with_uptake in [True, False]: + + if order == 1 and with_uptake: + continue + + fit_results, _ = exponential_fit_lmfit( + x_data=numpy.array(region_data["Time_hr"]), + y_data=numpy.array(region_data["Activity_MBq"]), + fixed_params=None, + num_exponentials=order, + bounds=self.config["VOIs"][region]["bounds"], + params_init={"A1": activity_init}, + with_uptake=with_uptake, + ) + + all_fits.append(fit_results) + fit_config.append((with_uptake, order)) + + # Apply Criterion + aic_results = [(idx, fit.aic) for idx, fit in enumerate(all_fits)] + aic_results = sorted(aic_results, key=lambda x: x[1]) # Sort + + # If only one model fit, that is the winner. + if len(aic_results) == 1: + self.config["VOIs"][region]["with_uptake"] = fit_config[0][0] + self.config["VOIs"][region]["fit_order"] = fit_config[0][1] + return all_fits[0] + + # If there are two more models, we check the top two models and compare their AIC. If the difference + # in AIC is less than 2, we pick the model with the lowest number of parameters. + + best_model_idx = aic_results[0][0] + + if ( + aic_results[1][1] - aic_results[0][1] <= 2 + and all_fits[aic_results[0][0]].nvarys > all_fits[aic_results[1][0]].nvarys + ): + best_model_idx = aic_results[1][0] + + self.config["VOIs"][region]["with_uptake"] = fit_config[best_model_idx][0] + self.config["VOIs"][region]["fit_order"] = fit_config[best_model_idx][1] + + return all_fits[best_model_idx] + + def analytical_integrate(self, result: lmfit.model.ModelResult) -> float: + """Compute the analytical integral of a fitted exponential function. + + This method calculates the analytical integral from 0 to infinity of a + fitted exponential function. It handles mono-, bi-, and tri-exponential + functions by summing the integrals of individual exponential terms. + + Parameters + ---------- + result : lmfit.model.ModelResult + The result object from fitting an exponential function using lmfit. + Should contain parameters for the exponential terms (A1, A2, B1, B2, etc.). + + Returns + ------- + float + The computed integral value. + + Notes + ----- + - For each exponential term, the integral is computed as A1/A2 where: + - A1 is the amplitude parameter + - A2 is the decay constant + - Terms with non-positive decay constants are ignored + - The function handles missing parameters gracefully + """ + # Extract the parameter values from the result + params = result.params.valuesdict() + + # Initialize integral + integral = 0.0 + + # Loop over the possible exponential terms + num_exponentials = len(params) // 2 # Each exponential has two parameters + terms = ["A", "B", "C"][:num_exponentials] + + for term in terms: + A1_name = f"{term}1" + A2_name = f"{term}2" + if A1_name in params and A2_name in params: + A1 = params[A1_name] + A2 = params[A2_name] + if A2 > 0: + integral += A1 / A2 + else: + # Handle the case where A2 is zero or negative + print( + f"Warning: Decay constant {A2_name} is non-positive ({A2}). Term is ignored in integral calculation." + ) + else: + # Parameters for this term are not present in the fit + continue + + return integral + + def normalize_time_to_injection(self, time_id: int) -> None: + """Express acquisition time corresponding to time_id in terms of injection time.""" + acq_time = f"{self.nm_data.meta[time_id].AcquisitionDate} {self.nm_data.meta[time_id].AcquisitionTime}" + inj_time = f"{self.config['InjectionDate']} {self.config['InjectionTime']}" + + self.nm_data.meta[time_id].HoursAfterInjection = calculate_time_difference( + date_str1=acq_time, date_str2=inj_time + ) + + return None + + @abc.abstractmethod + def compute_dose(self) -> None: + """Compute Dose to Organs and voxels. + + This abstract method must be implemented in all daughter dosimetry classes inheriting + from BaseDosimetry. Should run `compute_tia()` first. + """ + self.compute_tia() + return None + + def calculate_bed(self, kinetic: str) -> None: + """Calculate Biologically Effective Dose (BED). + + Monoexp equation based on the paper Bodei et al. "Long-term evaluation of renal toxicity + after peptide receptor radionuclide therapy with 90Y-DOTATOC and 177Lu-DOTATATE: the role + of associated risk factors". + """ + this_dir = Path(__file__).resolve().parent.parent + RADIOBIOLOGY_DATA_FILE = Path(this_dir, "data", "radiobiology.json") + with open(RADIOBIOLOGY_DATA_FILE) as f: + self.radiobiology_dic = json.load(f) + bed_df = self.results_dosimetry_organs[ + self.results_dosimetry_organs.index.isin(list(self.radiobiology_dic.keys())) + ] # only organs that we know the radiobiology parameters + organs = numpy.array(bed_df.index.unique()) + bed = {} + + for organ in organs: + t_repair = self.radiobiology_dic[organ]["t_repair"] + alpha_beta = self.radiobiology_dic[organ]["alpha_beta"] + AD = ( + float(bed_df.loc[bed_df.index == organ]["AD_total[Gy/GBq]"].values[0]) + * float(self.config["InjectedActivity"]) + / 1000 + ) # Gy + + if kinetic == "monoexp": + # gather existing kidneys dynamically + kidney_labels = [ + s for s in self.results.index if s.startswith("Kidney_") + ] + + # extract alpha parameters for those that exist + alphas = [self.results.loc[k]["Fit_params"][1] for k in kidney_labels] + + # compute effective half-time using the mean alpha + alpha_mean = numpy.mean(alphas) + t_eff = numpy.log(2) / alpha_mean + + bed[organ] = AD + 1 / alpha_beta * t_repair / (t_repair + t_eff) * AD**2 + + elif kinetic == "biexp": + mean_lambda_washout = ( + self.results.loc["Kidney_Left"]["Fit_params"][1] + + self.results.loc["Kidney_Right"]["Fit_params"][1] + ) / 2 + mean_lambda_uptake = ( + self.results.loc["Kidney_Left"]["Fit_params"][2] + + self.results.loc["Kidney_Right"]["Fit_params"][2] + ) / 2 + t_washout = numpy.log(2) / mean_lambda_washout + t_uptake = numpy.log(2) / mean_lambda_uptake + bed[organ] = AD * ( + 1 + + (AD / (t_washout - t_uptake)) + * (1 / alpha_beta) + * ( + ( + (2 * t_repair**4 * (t_washout - t_uptake)) + / ( + (t_repair**2 - t_washout**2) + * (t_repair**2 - t_uptake**2) + ) + ) + + ( + (2 * t_washout * t_uptake * t_repair) + / (t_washout**2 - t_uptake**2) + * ( + ((t_washout) / (t_repair - t_washout)) + + ((t_uptake) / (t_repair - t_uptake)) + ) + ) + - ( + ((t_repair) / (t_washout - t_uptake)) + * ( + ((t_washout**2) / (t_repair - t_washout)) + + ((t_uptake**2) / (t_repair - t_uptake)) + ) + ) + ) + ) + print(f"{organ}", bed[organ]) + + self.results_dosimetry_organs["BED[Gy]"] = ( + self.results_dosimetry_organs.index.map(bed) + ) + + def save_images_and_masks_at(self, time_id: int) -> None: + """Save CT, NM and masks for a specific time point. + + Args + ---- + time_id (int): The time point ID. + """ + self.ct_data.save_image_to_nii_at( + time_id=time_id, out_path=self.db_dir, name="CT" + ) + self.nm_data.save_image_to_nii_at( + time_id=time_id, out_path=self.db_dir, name="SPECT" + ) + self.nm_data.save_masks_to_nii_at( + time_id=time_id, out_path=self.db_dir, regions=self.config["VOIs"] + ) + + return None + + def write_json_data( + self, + file_path, + InstitutionName: str, + ClinicalTrial: str, + Radionuclide: str, + create_new: bool = True, + ) -> None: + """Write dosimetry results to a JSON file. + + If create_new is True, a new JSON file is created. If False, existing data is updated, + usually used to add results from subsequent cycles. + + Args + ---- + file_path (str): Path to the JSON file. + create_new (bool): Whether to create a new file or update existing data. + """ + # Open empty json to load its structure: + if create_new: + with resource_path("pytheranostics.data", "output.json") as template_json: + with template_json.open("r", encoding="utf-8") as file: + data = json.load(file) + else: + with open(file_path, "r", encoding="utf-8") as file: + data = json.load(file) + + data["PatientID"] = self.config["PatientID"] + data["InstitutionName"] = InstitutionName + data["ClinicalTrial"] = ClinicalTrial + data["Radionuclide"] = Radionuclide + data["Gender"] = self.config["Gender"] + data["No_of_completed_cycles"] = self.config["Cycle"] + + cycle_key = f"Cycle_{self.config['Cycle']:02d}" + + if cycle_key not in data: + data[cycle_key] = [{}] + else: + print( + f"WARNING: Might be Overwiting existing data. {cycle_key} in Patient {data['PatientID']} already exists." + ) + + cycle = data[cycle_key][0] + cycle["CycleNumber"] = self.config["Cycle"] + cycle["Operator"] = self.config["Operator"] + cycle["DatabaseDir"] = self.config["DatabaseDir"] + cycle["InjectionDate"] = self.config["InjectionDate"] + cycle["InjectionTime"] = self.config["InjectionTime"] + cycle["InjectedActivity"] = self.config["InjectedActivity"] + cycle["Weight_g"] = self.config["PatientWeight_g"] + cycle["Height_cm"] = self.config["PatientHeight_cm"] + cycle["Level"] = self.config["Level"] + if cycle["Level"] == "Organ": + cycle["Method"] = self.config["OrganLevel"] + elif cycle["Level"] == "Voxel": + cycle["Method"] = self.config["VoxelLevel"] + cycle["ScaleDoseByDensity"] = self.config.get( + "ScaleDoseByDensity", cycle.get("ScaleDoseByDensity", "NA") + ) + cycle["ReferenceTimePoint"] = self.config["ReferenceTimePoint"] + cycle["TimePoints_h"] = self.results["Time_hr"][0] + + for organ in self.config["VOIs"].keys(): + if organ not in cycle["VOIs"]: + cycle["VOIs"][organ] = { + "volumes_mL": {}, + "activity_MBq": {}, + "timepoints_h": {}, + "doserate_MBq_per_h": {}, + "density_HU": {}, + "density_gml": {}, + "mass_g": {}, + "composition": {}, + "fitting_eq": {}, + "no_of_fit_params": {}, + "fit_params": {}, + "fit_params_uncertainty": {}, + "R_2": {}, + "AIC": {}, + "TIA_MBqh": {}, + "TIA_MBqh_uncertainty": {}, + "TIA_h": {}, + "TIA_h_uncertainty": {}, + "total_s_value": {}, + "total_s_value_uncertainty": {}, + "mean_AD_Gy": {}, + "mean_AD_Gy_uncertainty": {}, + "min_AD_Gy": {}, + "max_AD_Gy": {}, + "peak_AD_Gy": {}, + "repair_halflife": {}, + "alpha_beta": {}, + "BED_Gy": {}, + "BED_Gy_uncertainty": {}, + } + + cycle["VOIs"][organ]["volumes_mL"]["different_tps"] = self.results.loc[ + organ, "Volume_CT_mL" + ] + cycle["VOIs"][organ]["volumes_mL"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["volumes_mL"]["mean"] = numpy.mean( + self.results.loc[organ, "Volume_CT_mL"] + ) + cycle["VOIs"][organ]["volumes_mL"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["activity_MBq"]["values"] = [ + float(x) for x in self.results.loc[organ, "Activity_MBq"] + ] + cycle["VOIs"][organ]["activity_MBq"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["timepoints_h"]["values"] = self.results.loc[ + organ, "Time_hr" + ] + cycle["VOIs"][organ]["doserate_MBq_per_h"]["values"] = "NA" + cycle["VOIs"][organ]["doserate_MBq_per_h"]["uncertainty"] = "NA" + try: + cycle["VOIs"][organ]["density_HU"]["different_tps"] = self.results.loc[ + organ, "Density_HU" + ] + except (KeyError, AttributeError): # TODO: Handle errors explicitly + pass + cycle["VOIs"][organ]["density_HU"]["uncertainty"] = "NA" + try: + cycle["VOIs"][organ]["density_HU"]["mean"] = numpy.mean( + self.results.loc[organ, "Density_HU"] + ) + except ( + KeyError, + AttributeError, + TypeError, + ): # TODO: Handle errors explicitly + pass + cycle["VOIs"][organ]["density_HU"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["density_gml"]["different_tps"] = "NA" + cycle["VOIs"][organ]["density_gml"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["density_gml"]["mean"] = "NA" + cycle["VOIs"][organ]["density_gml"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["mass_g"]["different_tps"] = "NA" + cycle["VOIs"][organ]["mass_g"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["mass_g"]["mean"] = "NA" + cycle["VOIs"][organ]["mass_g"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["fitting_eq"] = self.config["VOIs"][organ]["fit_order"] + cycle["VOIs"][organ]["no_of_fit_params"] = "NA" + cycle["VOIs"][organ]["fit_params"] = list( + self.results.loc[organ, "Fit_params"] + ) + cycle["VOIs"][organ]["washout_ratio"] = self.config["VOIs"][organ][ + "washout_ratio" + ] + cycle["VOIs"][organ]["fit_params_uncertainty"] = "NA" + cycle["VOIs"][organ]["R_2"] = ( + "NA" + if pandas.isna(self.results.loc[organ, "R_squared_AIC"][0]) + else self.results.loc[organ, "R_squared_AIC"][0] + ) + cycle["VOIs"][organ]["AIC"] = ( + "NA" + if pandas.isna(self.results.loc[organ, "R_squared_AIC"][1]) + else self.results.loc[organ, "R_squared_AIC"][1] + ) + cycle["VOIs"][organ]["TIA_MBqh"] = self.results.loc[organ, "TIA_MBq_h"] + cycle["VOIs"][organ]["TIA_MBqh_uncertainty"] = "NA" + cycle["VOIs"][organ]["TIA_h"] = self.results.loc[organ, "TIA_h"] + cycle["VOIs"][organ]["TIA_h_uncertainty"] = "NA" + cycle["VOIs"][organ]["mean_AD_Gy"] = "NA" + cycle["VOIs"][organ]["mean_AD_Gy_uncertainty"] = "NA" + cycle["VOIs"][organ]["min_AD_Gy"] = "NA" + cycle["VOIs"][organ]["max_AD_Gy"] = "NA" + cycle["VOIs"][organ]["peak_AD_Gy"] = "NA" + cycle["VOIs"][organ]["repair_halflife"] = "NA" + cycle["VOIs"][organ]["alpha_beta"] = "NA" + cycle["VOIs"][organ]["composition"] = "NA" + cycle["VOIs"][organ]["total_s_value"] = "NA" + cycle["VOIs"][organ]["total_s_value_uncertainty"] = "NA" + + if "Lesion" in organ or "TTB" in organ: + cycle["VOIs"][organ]["density_gml"]["different_tps"] = "NA" + cycle["VOIs"][organ]["density_gml"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["density_gml"]["mean"] = ( + self.results_dosimetry_lesions.loc[organ, "Density_g_per_mL"] + ) + cycle["VOIs"][organ]["density_gml"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["mass_g"]["different_tps"] = "NA" + cycle["VOIs"][organ]["mass_g"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["mass_g"]["mean"] = ( + self.results_dosimetry_lesions.loc[organ, "Mass_g"] + ) + cycle["VOIs"][organ]["mass_g"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["composition"] = ( + self.results_dosimetry_lesions.loc[organ, "Composition"] + ) + cycle["VOIs"][organ]["total_s_value"] = ( + self.results_dosimetry_lesions.loc[organ, "Total_S_Value"] + ) + cycle["VOIs"][organ]["total_s_value_uncertainty"] = "NA" + cycle["VOIs"][organ]["mean_AD_Gy"] = self.results_dosimetry_lesions.loc[ + organ, "AD_Gy" + ] + cycle["VOIs"][organ]["mean_AD_Gy_uncertainty"] = "NA" + + if "BoneMarrow" in organ: + cycle["VOIs"][organ]["volumes_mL"]["different_tps"] = 1170 + cycle["VOIs"][organ]["volumes_mL"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["volumes_mL"]["mean"] = 1170 + + if "Gland" in organ: + cycle["VOIs"][organ]["density_gml"]["different_tps"] = "NA" + cycle["VOIs"][organ]["density_gml"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["density_gml"]["mean"] = ( + self.results_dosimetry_salivaryglands.loc[organ, "Density_g_per_mL"] + ) + cycle["VOIs"][organ]["density_gml"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["mass_g"]["different_tps"] = "NA" + cycle["VOIs"][organ]["mass_g"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["mass_g"]["mean"] = ( + self.results_dosimetry_salivaryglands.loc[organ, "Mass_g"] + ) + cycle["VOIs"][organ]["mass_g"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["composition"] = ( + self.results_dosimetry_salivaryglands.loc[organ, "Composition"] + ) + cycle["VOIs"][organ]["total_s_value"] = ( + self.results_dosimetry_salivaryglands.loc[organ, "Total_S_Value"] + ) + cycle["VOIs"][organ]["total_s_value_uncertainty"] = "NA" + cycle["VOIs"][organ]["mean_AD_Gy"] = ( + self.results_dosimetry_salivaryglands.loc[organ, "AD_Gy"] + ) + cycle["VOIs"][organ]["mean_AD_Gy_uncertainty"] = "NA" + + if self.config["Level"] == "Organ": + for organ in self.results_dosimetry_organs.index: + if organ in self.results_dosimetry_organs.index: + cycle["Organ-level_AD"][organ] = { + "AD[Gy/GBq]": {}, + "AD[Gy/GBq]_uncertianty": {}, + "AD[Gy]": {}, + "AD[Gy]_uncertianty": {}, + "BED[Gy]": {}, + "BED[Gy]_uncertianty": {}, + } + cycle["Organ-level_AD"][organ]["AD[Gy/GBq]"] = ( + self.results_dosimetry_organs.loc[organ, "AD_total[Gy/GBq]"] + ) + cycle["Organ-level_AD"][organ]["AD[Gy/GBq]_uncertainty"] = "NA" + cycle["Organ-level_AD"][organ]["AD[Gy]"] = ( + self.results_dosimetry_organs.loc[organ, "AD_total[Gy]"] + ) + cycle["Organ-level_AD"][organ]["AD[Gy]_uncertainty"] = "NA" + + if "BED[Gy]" in self.results_dosimetry_organs.columns: + cycle["Organ-level_AD"][organ]["BED[Gy]"] = ( + self.results_dosimetry_organs.loc[organ, "BED[Gy]"] + if pandas.notna( + self.results_dosimetry_organs.loc[organ, "BED[Gy]"] + ) + else "NA" + ) + else: + cycle["Organ-level_AD"][organ]["BED[Gy]"] = "NA" + + cycle["Organ-level_AD"][organ]["BED[Gy]_uncertianty"] = "NA" + + if "Yes" in self.config["OrganLevel"]["AdditionalOptions"].get( + "LesionDosimetry" + ): + cycle["Organ-level_AD"]["TTB"] = { + "mass_g": {}, + "volumes_mL": {}, + "TIA_h": {}, + "AD[Gy]": {}, + "AD[Gy]_uncertianty": {}, + "AD[Gy/GBq]": {}, + "AD[Gy/GBq]_uncertianty": {}, + } + cycle["Organ-level_AD"]["TTB"]["mass_g"] = ( + self.results_dosimetry_lesions.loc["TTB", "Mass_g"] + ) + cycle["Organ-level_AD"]["TTB"]["volumes_mL"] = ( + self.results_dosimetry_lesions.loc["TTB", "Volume_CT_mL"] + ) + cycle["Organ-level_AD"]["TTB"]["TIA_h"] = ( + self.results_dosimetry_lesions.loc["TTB", "TIA_h"] + ) + cycle["Organ-level_AD"]["TTB"]["AD[Gy]"] = ( + self.results_dosimetry_lesions.loc["TTB", "AD_Gy"] + ) + cycle["Organ-level_AD"]["TTB"]["AD[Gy]_uncertainty"] = "NA" + cycle["Organ-level_AD"]["TTB"]["AD[Gy/GBq]"] = ( + self.results_dosimetry_lesions.loc["TTB", "AD_Gy"] + / (float(self.config["InjectedActivity"]) / 1000) + ) + cycle["Organ-level_AD"]["TTB"]["AD[Gy/GBq]_uncertianty"] = "NA" + + with open(file_path, "w") as file: + json.dump(data, file, indent=4) diff --git a/pytheranostics/dosimetry/bone_marrow.py b/pytheranostics/dosimetry/bone_marrow.py new file mode 100644 index 0000000..f033210 --- /dev/null +++ b/pytheranostics/dosimetry/bone_marrow.py @@ -0,0 +1,48 @@ +"""Bone marrow dosimetry scaling factors. + +This module provides functions for calculating bone marrow scaling factors +used in blood-based dosimetry methods. +""" + +from typing import Optional + +from pytheranostics.dosimetry.olinda import load_phantom_mass + + +def bm_scaling_factor( + gender: str = "Male", + mass_bm: Optional[float] = None, + hematocrit: Optional[float] = None, +) -> float: + """Calculate bone marrow scaling factor for blood-based dosimetry. + + Determines the scaling factor to estimate integrated activity in bone marrow + from activity concentration in blood measurements. + + Parameters + ---------- + gender : str, optional + Patient gender ("Male" or "Female"), by default "Male" + mass_bm : float, optional + Bone marrow mass in grams. If None, uses phantom mass, by default None + hematocrit : float, optional + Patient hematocrit value. If None, uses default RMBLR of 1.0, by default None + + Returns + ------- + float + Scaling factor for converting blood activity to bone marrow activity. + """ + RMBLR = 1.0 # Activity concentration in Red Marrow over blood. Generally 1 for PSMA and SSRT. + + if hematocrit is not None: + RMBLR = 0.19 / (1 - hematocrit) + + # If the bone-marrow mass is unknown, use phantom mass. + if mass_bm is None: + mass_bm = load_phantom_mass(gender=gender, organ="Red Marrow") # in Grams. + + print(f"Phantom Bone Marrow Mass (in grams): {mass_bm}") + print(f"Red Marrow to Blood Ratio: RMBLR = {RMBLR}") + + return RMBLR * mass_bm diff --git a/pytheranostics/dosimetry/dosiomicsclass.py b/pytheranostics/dosimetry/dosiomicsclass.py new file mode 100644 index 0000000..8b6c5d9 --- /dev/null +++ b/pytheranostics/dosimetry/dosiomicsclass.py @@ -0,0 +1,64 @@ +"""Radiomics feature extraction utilities.""" + +from __future__ import print_function + +import os + +import pandas as pd +import SimpleITK as sitk +import six +from radiomics import featureextractor + + +class Radiomics: + """Generate radiomics features for longitudinal studies.""" + + def __init__(self, imagemodality, patient_id, cycle, image, mask, organslist): + """Store study metadata, image arrays, and ROI masks.""" + self.imagemodality = imagemodality + self.patient_id = patient_id + self.cycle = cycle + self.image = image + self.mask = mask + self.organslist = organslist + + def prepareimages(self): + """Export the image and ROI masks to NRRD files for PyRadiomics.""" + img = sitk.GetImageFromArray(self.image) + + sitk.WriteImage( + img, + f"/mnt/y/Sara/PR21_dosimetry/{self.patient_id}/cycle0{self.cycle}/radiomics/{self.imagemodality}.nrrd", + ) + for organ in self.organslist: + self.mask[organ] = self.mask[organ].astype(int) + img = sitk.GetImageFromArray(self.mask[organ]) + sitk.WriteImage( + img, + f"/mnt/y/Sara/PR21_dosimetry/{self.patient_id}/cycle0{self.cycle}/radiomics/{organ}.nrrd", + ) + + def featureextractor(self): + """Run PyRadiomics using the configured parameter set.""" + paramPath = os.path.join("..", "data", "Params.yaml") + + extractor = featureextractor.RadiomicsFeatureExtractor(paramPath) + + radiomics_list = [] + for organ in self.organslist: + imagepath = f"/mnt/y/Sara/PR21_dosimetry/{self.patient_id}/cycle0{self.cycle}/dosiomics/{self.imagemodality}.nrrd" + maskpath = f"/mnt/y/Sara/PR21_dosimetry/{self.patient_id}/cycle0{self.cycle}/dosiomics/{organ}.nrrd" + result = extractor.execute(imagepath, maskpath) + + data = {"organ": organ} + for key, value in six.iteritems(result): + data[key] = value + + radiomics_list.append(data) + + radiomics_df = pd.DataFrame(radiomics_list) + radiomics_df.to_csv( + f"/mnt/y/Sara/PR21_dosimetry/output/{self.patient_id}_cycle0{self.cycle}_radiomics_{self.imagemodality}_output.csv" + ) + + return radiomics_df diff --git a/pytheranostics/dosimetry/dvk.py b/pytheranostics/dosimetry/dvk.py new file mode 100644 index 0000000..96822f8 --- /dev/null +++ b/pytheranostics/dosimetry/dvk.py @@ -0,0 +1,90 @@ +"""Dose voxel kernel module for convolution-based dosimetry.""" + +from typing import Optional + +import numpy +from scipy import signal + +from pytheranostics.misc_tools.tools import hu_to_rho +from pytheranostics.shared.resources import resource_path + + +class DoseVoxelKernel: + """Dose Voxel Kernel for convolution-based dosimetry calculations.""" + + def __init__(self, isotope: str, voxel_size_mm: float) -> None: + """Initialize the DoseVoxelKernel. + + Args + ---- + isotope (str): The isotope name (e.g., 'Lu177'). + voxel_size_mm (float): Voxel size in millimeters. + """ + kernel_filename = ( + f"voxel_kernels/{isotope}-{voxel_size_mm:1.2f}-mm-mGyperMBqs-SoftICRP.img" + ) + try: + with resource_path("pytheranostics.data", kernel_filename) as kernel_path: + self.kernel = numpy.fromfile(kernel_path, dtype=numpy.float32) + except FileNotFoundError: + print( + f" >> Voxel Kernel for SPECT voxel size ({voxel_size_mm:2.2f} mm) not found. Using default kernel for 4.8 mm voxels..." + ) + + fallback_filename = ( + f"voxel_kernels/{isotope}-4.80-mm-mGyperMBqs-SoftICRP.img" + ) + with resource_path("pytheranostics.data", fallback_filename) as kernel_path: + self.kernel = numpy.fromfile(kernel_path, dtype=numpy.float32) + + self.kernel = self.kernel.reshape((51, 51, 51)).astype(numpy.float64) + + def tia_to_dose( + self, tia_mbq_s: numpy.ndarray, ct: Optional[numpy.ndarray] = None + ) -> numpy.ndarray: + """Convert Time-Integrated Activity to dose. + + Parameters + ---------- + tia_mbq_s : numpy.ndarray + Time-integrated activity in MBq*s. + ct : numpy.ndarray, optional + CT image in HU for density weighting. + + Returns + ------- + numpy.ndarray + Dose map in mGy. + """ + dose_mGy = signal.fftconvolve(tia_mbq_s, self.kernel, mode="same", axes=None) + + if ct is not None: + # TODO: Handle erroneous scale-up of dose outside of body. + print( + "Warning -> Scaling dose by density will yield erroneous dose values in very low density voxels (e.g., air inside the body)." + " Please use at your own risk" + ) + dose_mGy = self.weight_dose_by_density(dose_map=dose_mGy, ct=ct) + + return dose_mGy + + def weight_dose_by_density( + self, dose_map: numpy.ndarray, ct: numpy.ndarray + ) -> numpy.ndarray: + """Scale dose per voxel by voxel density. + + This is only valid for voxels of density similar to that of soft tissue and will also improve results for voxels + with higher density of soft tissue in some instances. However, it will over-estimate doses in voxels with lower density than soft tissue. + To prevent dose to shoot-up in areas of air where there is activity present (e.g., in the patient's gut), we do not apply scaling based on density in those voxels (i.e., we apply a factor of 1, which is equivalent to saying + the tissue is ~ soft tissue). + + Args: + dose_map (numpy.ndarray): Dose-map obtained from convolution of TIA map and Dose Kernel. + ct (numpy.ndarray): CT image, in HU. + + Returns + ------- + numpy.ndarray + Modified Dose-map with dose per voxel scaled-up by density. + """ + return 1 / hu_to_rho(hu=numpy.clip(ct, 0, 99999)) * dose_map diff --git a/pytheranostics/dosimetry/image_analysis.py b/pytheranostics/dosimetry/image_analysis.py new file mode 100644 index 0000000..5a938fb --- /dev/null +++ b/pytheranostics/dosimetry/image_analysis.py @@ -0,0 +1,109 @@ +"""Visualization and summary utilities for volumetric dosimetry images.""" + +import gatetools as gt +import itk +import matplotlib.pyplot as plt +import numpy as np + + +class Image: + """Convenience wrapper to compute statistics on organ masks.""" + + def __init__(self, df, patient_id, cycle, image, roi_masks_resampled): + """Store metadata, image array, and resampled ROI masks.""" + self.df = df + self.patient_id = patient_id + self.cycle = int(cycle) + self.image = image + self.roi_masks_resampled = roi_masks_resampled + self.organlist = self.roi_masks_resampled.keys() + + def SPECT_image_array(self, SPECT, scalefactor, xspacing, yspacing, zspacing): + """Convert a DICOM SPECT series into an MBq volume and cache it.""" + SPECT_image = SPECT.pixel_array + SPECT_image = np.transpose( + SPECT_image, (1, 2, 0) + ) # from (233,128,128) to (128,128,233) + SPECT_image.astype(np.float64) + scalefactor = SPECT.RealWorldValueMappingSequence[0].RealWorldValueSlope + SPECT_image = SPECT_image * scalefactor # in Bq/ml + SPECTMBq = ( + SPECT_image + * ( + SPECT.PixelSpacing[0] + * SPECT.PixelSpacing[1] + * SPECT.SpacingBetweenSlices + / 1000 + ) + / 1e6 + ) # in MBq + self.image = SPECTMBq + return SPECTMBq + + def image_visualisation(self, image): + """Display three orthogonal slices for quick sanity checks.""" + fig, axs = plt.subplots(1, 3, figsize=(15, 5)) + + axs[0].imshow(image[:, :, 50]) + axs[0].set_title("Slice at index 50") + axs[1].imshow(image[69, :, :].T) + axs[1].set_title("Slice at index 100") + axs[2].imshow(image[:, 60, :]) + axs[2].set_title("Slice at index 47") + + plt.tight_layout() + plt.show() + + def show_mean_statistics(self, output): + """Compute mean activity per organ and store in the dataframe.""" + self.ad_mean = {} + for organ in self.organlist: + mask = self.roi_masks_resampled[organ] + self.ad_mean[organ] = self.image[mask].mean() + print(f"{organ}", self.ad_mean[organ]) + + self.df[output] = self.df["Contour"].map(self.ad_mean) + + def show_max_statistics(self): + """Print the maximum activity observed within each organ mask.""" + for organ in self.organlist: + mask = self.roi_masks_resampled[organ] + x = self.image[mask].max() + print(f"{organ}", x) + + def add(self, output): + """Compute total activity per organ and map it back into the dataframe.""" + self.sum = {} + for organ in self.organlist: + mask = self.roi_masks_resampled[organ] + self.sum[organ] = self.image[mask].sum() + print(f"{organ}", self.sum[organ]) + + self.df[output] = self.df["Contour"].map(self.sum) + + def voxels_and_volume(self, output1, output2, voxel_volume): + """Record nonzero voxel counts and physical volumes per organ.""" + self.no_voxels = {} + self.volume = {} + for organ in self.organlist: + mask = self.roi_masks_resampled[organ] + self.no_voxels[organ] = np.sum(self.image[mask] != 0) + self.volume[organ] = self.no_voxels[organ] * voxel_volume + + self.df[output1] = self.df["Contour"].map(self.no_voxels) + self.df[output2] = self.df["Contour"].map(self.volume) + + def dose_volume_histogram(self): + """Generate and log dose-volume histograms for each organ.""" + doseimage = self.image.astype(float) + doseimage = itk.image_from_array(doseimage) + + for organ in self.organlist: + x = self.roi_masks_resampled[organ] + itkVol = x.astype(float) + itkVol = itk.GetImageFromArray(itkVol) + print(type(itkVol)) + zip = itk.LabelStatisticsImageFilter.New(doseimage) + zip = zip.SetLabelInput() + organ_data = gt.createDVH(doseimage, itkVol) + print(organ_data) diff --git a/pytheranostics/dosimetry/mc.py b/pytheranostics/dosimetry/mc.py new file mode 100644 index 0000000..82a5f66 --- /dev/null +++ b/pytheranostics/dosimetry/mc.py @@ -0,0 +1,38 @@ +"""Helpers to orchestrate Monte Carlo batch jobs.""" + +import os + + +class MonteCarlo: + """Split and execute Monte Carlo runs across multiple CPUs.""" + + def __init__(self, n_cpu, n_primaries, output_dir): + """Store execution parameters for the simulation batch.""" + self.n_cpu = n_cpu + self.n_primaries = n_primaries + self.output_dir = output_dir + + def split_simulations(self): + """Split total primaries across CPUs and write per-core macro files.""" + n_primaries_per_mac = int(self.n_primaries / self.n_cpu) + + with open("./main_template.mac", "r") as mac_file: + filedata = mac_file.read() + + for i in range(0, self.n_cpu): + new_mac = filedata + + new_mac = new_mac.replace("distrib-SPLIT.mhd", f"distrib_SPLIT_{i+1}.mhd") + new_mac = new_mac.replace("stat-SPLIT.txt", f"stat__SPLIT_{i+1}.txt") + new_mac = new_mac.replace("XXX", str(n_primaries_per_mac)) + + with open( + f"{self.output_dir}/main_normalized_{i+1}.mac", "w" + ) as output_mac: + output_mac.write(new_mac) + + def run_MC(self): + """Invoke the shell script that runs the Monte Carlo jobs.""" + os.system( + f"bash {self.output_dir}/runsimulation1.sh {self.output_dir} {self.n_cpu}" + ) diff --git a/pytheranostics/dosimetry/olinda.py b/pytheranostics/dosimetry/olinda.py new file mode 100644 index 0000000..9b2f2a6 --- /dev/null +++ b/pytheranostics/dosimetry/olinda.py @@ -0,0 +1,35 @@ +"""Helpers for reading Olinda/EXM phantom tables shipped with PyTheranostics.""" + +import pandas + +from pytheranostics.shared.resources import resource_path + + +def load_s_values(gender: str, radionuclide: str) -> pandas.DataFrame: + """Load the S-value table for a gender/radionuclide pair.""" + relative_path = f"s-values/organ/{radionuclide}-{gender}-Svalues.csv" + try: + with resource_path("pytheranostics.data", relative_path) as path_to_sv: + s_df = pandas.read_csv(path_to_sv) + except FileNotFoundError as exc: # pragma: no cover - defensive + raise FileNotFoundError( + f"S-values for {gender}, {radionuclide} not found. Ensure gender is " + "one of ['Male', 'Female'] and radionuclide uses the SymbolMass format (e.g., Lu177)." + ) from exc + s_df.set_index(keys=["Target"], drop=True, inplace=True) + s_df = s_df.drop(labels=["Target"], axis=1) + + return s_df + + +def load_phantom_mass(gender: str, organ: str) -> float: + """Return the ICRP phantom mass for the requested organ and gender.""" + with resource_path( + "pytheranostics.data", "phantom/human/human_phantom_masses.csv" + ) as phantom_data_path: + masses = pandas.read_csv(phantom_data_path) + + if organ not in masses["Organ"].to_list(): + raise ValueError(f"Organ {organ} not found in phantom data.") + + return masses.loc[masses["Organ"] == organ].iloc[0][gender] diff --git a/pytheranostics/dosimetry/organ_s_dosimetry.py b/pytheranostics/dosimetry/organ_s_dosimetry.py new file mode 100644 index 0000000..5223910 --- /dev/null +++ b/pytheranostics/dosimetry/organ_s_dosimetry.py @@ -0,0 +1,814 @@ +"""Organ S-value dosimetry class. + +Perform organ-level, patient-specific dosimetry using organ S-values. +Currently supports export to Olinda/EXM. +""" + +import datetime +import re +from os import makedirs, path +from typing import Any, Dict, Optional, Tuple + +import numpy +import pandas +from scipy.interpolate import PchipInterpolator + +from pytheranostics.dosimetry.base_dosimetry import BaseDosimetry +from pytheranostics.imaging_ds.longitudinal_study import LongitudinalStudy +from pytheranostics.shared.resources import resource_path + + +class OrganSDosimetry(BaseDosimetry): + """Organ S-value dosimetry class. + + Perform organ-level, patient-specific dosimetry using organ S-values. + Currently supports export to Olinda/EXM. + """ + + def __init__( + self, + config: Dict[str, Any], + nm_data: LongitudinalStudy, + ct_data: Optional[LongitudinalStudy], + clinical_data: Optional[pandas.DataFrame] = None, + ) -> None: + super().__init__(config, nm_data, ct_data, clinical_data) + self.check_mandatory_fields_organ() + + """Inputs: + config: Configuration parameters for dosimetry calculations, a Dict. + Note: defined VOIs should have the same naming convention as source organs in Olinda. + We included method prepare_df() that combines kidneys and salivary glands into one VOI. + nm_data: longitudinal, quantitative, nuclear-medicine imaging data, type LongitudinalStudy. + Note: voxel values should be in units of Bq/mL. + ct_data: longitudinal CT imaging data, type LongitudinalStudy, + Note: voxel values should be in HU units. + clinical_data: clinical data such as blood sampling, an optional pandas DataFrame. + Note: blood counting should be in units of Bq/mL. + """ + + def check_mandatory_fields_organ(self) -> None: + """Check for mandatory fields in the configuration for organ-level dosimetry.""" + if "Organ" not in self.config["Level"]: + print("Verify the level on which dosimetry should be performed.") + + return None + + # @staticmethod + def _load_human_mass_target_organs_table(self) -> pandas.DataFrame: + """Load the reference human phantom masses.""" + with resource_path( + "pytheranostics.data", + f"phantom/human/ICRP_mass_{self.config['Gender'].lower()}_target.csv", + ) as masses_path: + self.target_organ_masses = pandas.read_csv(masses_path, index_col=0) + + def _load_human_mass_source_organs_table(self) -> pandas.DataFrame: + """Load the reference human phantom masses.""" + with resource_path( + "pytheranostics.data", + f"phantom/human/ICRP_mass_{self.config['Gender'].lower()}_source.csv", + ) as masses_path: + self.source_organ_masses = pandas.read_csv(masses_path, index_col=0) + + def composition_and_density_from_HU(self, density: float) -> Tuple[str, float]: + """Determine composition and density for a given CT HU value.""" + if density <= 100: + return "100%/0%", 1.03 + elif density <= 250: + return "75%/25%", 1.255 + elif density <= 500: + return "50%/50%", 1.48 + elif density <= 750: + return "25%/75%", 1.7 + else: + return "0%/100%", 1.92 + + def s_value_from_mass(self, mass: float, composition: str) -> float: + """Return interpolated total S value (mGy MBq^-1 h^-1) for mass and composition.""" + # Select the appropriate tumor mass and s_value arrays based on composition + mass_data = self.mass_and_s_values[composition]["tumor_mass"] + s_value_data = self.mass_and_s_values[composition]["total_s_value"] + + # Perform PCHIP interpolation + pchip_interpolator = PchipInterpolator(mass_data, s_value_data) + + # Interpolate and return the result in mGy MBq^-1 h^-1 + return pchip_interpolator(mass) * 3.6 * 10**12 + + def apply_sphere_method(self, df: pandas.DataFrame) -> pandas.DataFrame: + """Compute absorbed dose using the sphere method.""" + df = df.copy() + + # Compute mean volume and density + df["Volume_CT_mL"] = df["Volume_CT_mL"].apply(lambda x: numpy.mean(x)) + df["Density_HU"] = df["Density_HU"].apply(lambda x: numpy.mean(x)) + + # Compute composition and density from HU + df[["Composition", "Density_g_per_mL"]] = df["Density_HU"].apply( + lambda x: pandas.Series(self.composition_and_density_from_HU(x)) + ) + + # Calculate mass + df["Mass_g"] = df["Density_g_per_mL"] * df["Volume_CT_mL"] + + # Calculate total S-value + df["Total_S_Value"] = df.apply( + lambda row: self.s_value_from_mass(row["Mass_g"], row["Composition"]), + axis=1, + ) + + # Calculate absorbed dose in Gy + injected_activity = float(self.config["InjectedActivity"]) + df["AD_Gy"] = ( # Gy + df["TIA_h"] # h + * df["Total_S_Value"] # mGy MBq^-1 h^-1 + * injected_activity # MBq + / 1000 + ) + return df + + def calculate_ttb(self): + """Compute Total Tumor Burden (TTB) metrics and append to results_lesions.""" + metrics = { + "Mass_g": self.results_dosimetry_lesions["Mass_g"].sum(), + "Volume_CT_mL": self.results_dosimetry_lesions["Volume_CT_mL"].sum(), + "TIA_h": self.results_dosimetry_lesions["TIA_h"].sum(), + "AD_Gy": ( + ( + self.results_dosimetry_lesions["Mass_g"] + * self.results_dosimetry_lesions["AD_Gy"] + ).sum() + ) + / ( + self.results_dosimetry_lesions["Mass_g"].sum() + if self.results_dosimetry_lesions["Mass_g"].sum() > 0 + else 0 + ), + } + + TTB = pandas.DataFrame(metrics, index=["TTB"]) + self.results_dosimetry_lesions = pandas.concat( + [self.results_dosimetry_lesions, TTB], axis=0 + ) + return self.results_dosimetry_lesions + + def prepare_data(self) -> None: + """ + Prepare data for dosimetry calculations or export based on the configuration. + + For organ-level workflows the method either exports data compatible with + Olinda/MIRDcalc or performs the configured calculation, sourcing S-values + from the selected tables and honoring options such as ROB, lesions, or + salivary gland handling. For voxel-level workflows it assembles the inputs + for kernel-based calculations and writes the data using the requested + voxel-level format (for example, NIfTI). + """ + self.results_fitting = self.results[["Volume_CT_mL", "TIA_h"]].copy() + # Average Volume over time points. + self.results_fitting["Volume_CT_mL"] = self.results_fitting[ + "Volume_CT_mL" + ].apply(lambda x: numpy.mean(x)) + if "Organ" in self.config["Level"]: + organ_conf = self.config["OrganLevel"] + output_type = organ_conf["Output"]["Type"] + print(output_type) + + # Average Volume over time points. + self.results_fitting["Volume_CT_mL"] = self.results_fitting[ + "Volume_CT_mL" + ].apply(lambda x: numpy.mean(x)) + + # Combine Kidneys. + kidneys = [ + s for s in self.results_fitting.index if s.startswith("Kidney_") + ] # e.g., Kidney_Left, Kidney_Right, or one kidney only + self.results_fitting.loc["Kidneys"] = self.results_fitting.loc[ + kidneys + ].sum() + self.results_fitting = self.results_fitting.drop(kidneys) + + # Combine Salivary Glands. + sal_glands = [ + "ParotidGland_Left", + "ParotidGland_Right", + "SubmandibularGland_Left", + "SubmandibularGland_Right", + ] + if "Yes" in self.config["OrganLevel"]["AdditionalOptions"].get( + "SalivaryGlandsSeparately" + ): + self.results_salivaryglands = self.results[ + self.results.index.str.contains("Gland") + ] + else: + pass + self.results_fitting.loc["Salivary Glands"] = self.results_fitting.loc[ + sal_glands + ].sum() + self.results_fitting = self.results_fitting.drop(sal_glands) + + if "Skeleton" in self.results_fitting.index: + skeleton_row = self.results_fitting.loc["Skeleton"] + + # Based on ICRP publication 70; need to be verified for specific cases (different bones have different proportions) + trabecular = skeleton_row * 0.62 + cortical = skeleton_row * 0.38 + + self.results_fitting.loc["Trabecular Bone"] = trabecular + self.results_fitting.loc["Cortical Bone"] = cortical + + # Drop the original "Skeleton" entry + self.results_fitting = self.results_fitting.drop("Skeleton") + + self.results_fitting = self.results_fitting.drop(["WholeBody"]) + + # Rename + self.results_fitting = self.results_fitting.rename( + index={ + "Bladder": "Urinary Bladder Contents", + "BoneMarrow": "Red Marrow", + } + ) + + self.results_fitting.loc["Red Marrow"][ + "Volume_CT_mL" + ] = 1170 # TODO volume hardcoded, think about alternatives #this one works for blood based method only; for imaging method it should be scaled # EANM Dosimetry Committee guidelines for bone marro and whole-body dosimetry + + self.results_fitting.loc["RemainderOfBody"]["Volume_CT_mL"] = ( + self.config["PatientWeight_g"] + - self.results_fitting.loc[ + ~self.results_fitting.index.isin(["Total Body", "RemainderOfBody"]), + "Volume_CT_mL", + ].sum() + ) + + if "Yes" in self.config["OrganLevel"]["AdditionalOptions"].get( + "LesionDosimetry" + ): + # Separate dosimetry results into lesions and non-lesions + lesion_mask = self.results_fitting.index.str.contains( + "Lesion", case=False, na=False + ) + self.results_fitting_organs = self.results_fitting[ + ~lesion_mask + ].copy() # all non-lesion entries + self.results_fitting_lesions = self.results[ + self.results.index.str.contains("Lesion") + ] + elif "No" in self.config["OrganLevel"]["AdditionalOptions"].get( + "LesionDosimetry" + ): + self.results_fitting_organs = self.results_fitting.copy() + if "TotalTumorBurden" in self.results_fitting.index: + self.results_fitting.drop("TotalTumorBurden", axis=0, inplace=True) + if output_type == "Export": + fmt = organ_conf["Output"]["ExportFormat"] + if fmt.lower() == "olinda": + self.results_fitting_organs = self.results_fitting_organs.rename( + index={"RemainderOfBody": "Total Body"} + ) + self.results_fitting_organs.loc["Total Body"]["Volume_CT_mL"] = ( + self.config["PatientWeight_g"] + - self.results_fitting_organs.loc[ + ~self.results_fitting_organs.index.isin( + ["Total Body", "RemainderOfBody"] + ), + "Volume_CT_mL", + ].sum() + ) + elif fmt.lower() == "mirdcalc": + print("Not Implemented yet.") + else: + print(f"Export format {fmt} not recognized.") + elif "Lesion" in self.config["Level"]: + self.results_fitting_lesions = self.results[ + self.results.index.str.contains("Lesion") + ] + + return None + + def create_output_file(self, dirname: str, savefile: bool = False) -> None: + """Create output file(s) for the selected organ-level output format.""" + if self.config["OutputFormat"] == "Olinda": + self.create_Olinda_file(dirname, savefile) + else: + print( + "In the current version, we only support Olinda as external organ S-value software." + ) # TODO: other software case files + + def load_svalues(self, filepath): + """Load S-values CSV and rename Olinda column names to human-friendly organ names.""" + svalues_df = pandas.read_csv(filepath, index_col=0) + olinda_to_human_source_organ_map = { + "GB Cont": "Gallbladder Contents", + "StomCont": "Stomach Contents", + "Salivary": "Salivary Glands", + "Red Mar.": "Red Marrow", + "CortBone": "Cortical Bone", + "Hrt Wall": "Heart Wall", + "TrabBone": "Trabecular Bone", + "HeartCon": "Heart Contents", + "SI Cont": "Small Intestine", + "UB Cont": "Urinary Bladder Contents", + "Tot Body": "Total Body", + } + return svalues_df.rename(columns=olinda_to_human_source_organ_map) + + def process_dosimetry(self) -> None: + """Execute the main dosimetry workflow (prepare data, export or calculate).""" + organ_conf = self.config["OrganLevel"] + output_type = organ_conf["Output"]["Type"] # 'Export' or 'Calculate' + + self.prepare_data() + print("Processing dosimetry at the organ level of OARs") + if output_type == "Export": + fmt = organ_conf["Output"]["ExportFormat"] + if fmt.lower() == "olinda": + print("Creating .cas file for Olinda/EXM export.") + self.create_Olinda_file( + dirname=organ_conf["Output"]["ExportDirectory"], savefile=True + ) + elif fmt.lower() == "mirdcalc": + print("Not Implemented yet.") + else: + print(f"Export format {fmt} not recognized.") + + elif output_type == "Calculate": + if "Organ" in self.config["Level"]: + self.calculate_absorbed_dose() + + else: + raise ValueError(f"Unknown output type: {output_type}") + + if "Yes" in self.config["OrganLevel"]["AdditionalOptions"].get( + "LesionDosimetry" + ): + self.results_dosimetry_lesions = self.apply_sphere_method( + self.results_fitting_lesions + ) + self.results_dosimetry_lesions = self.calculate_ttb() + if "Yes" in self.config["OrganLevel"]["AdditionalOptions"].get( + "SalivaryGlandsSeparately" + ): + print("Processing dosimetry of salivary glands") + self.results_dosimetry_salivaryglands = self.apply_sphere_method( + self.results_salivaryglands + ) + + def calculate_absorbed_dose(self) -> pandas.DataFrame: + """Calculate absorbed dose per target organ based on model and disintegration data.""" + model_files = { + "Female": { + "gamma": f'{self.config["Radionuclide"]}_S_values_female_{self.config["OrganLevel"]["Calculation"]["SValueSource"].lower()}_GAMMA.csv', + "beta": f'{self.config["Radionuclide"]}_S_values_female_{self.config["OrganLevel"]["Calculation"]["SValueSource"].lower()}_BETA.csv', + }, + "Male": { + "gamma": f'{self.config["Radionuclide"]}_S_values_male_{self.config["OrganLevel"]["Calculation"]["SValueSource"].lower()}_GAMMA.csv', + "beta": f'{self.config["Radionuclide"]}_S_values_male_{self.config["OrganLevel"]["Calculation"]["SValueSource"].lower()}_BETA.csv', + }, + } + + with resource_path( + "pytheranostics.data", + f"s-values/{model_files[self.config['Gender']]['beta']}", + ) as beta_path: + svalues_beta = self.load_svalues(beta_path) + with resource_path( + "pytheranostics.data", + f"s-values/{model_files[self.config['Gender']]['gamma']}", + ) as gamma_path: + svalues_gamma = self.load_svalues(gamma_path) + + print("Source organs available in the model:", svalues_beta.columns.tolist()) + print("Source organs present :", self.results_fitting_organs.index.tolist()) + + self._load_human_mass_target_organs_table() + self._load_human_mass_source_organs_table() + + self.source_organs_missing = set(svalues_beta.columns) - set( + self.results_fitting.index + ) + print(f"Source organs missing in DataFrame: {self.source_organs_missing}") + + self.results_fitting["TIA_s"] = ( + self.results_fitting["TIA_h"] * 3600 + ) # Time-integrated activity in s + + # Apply S-values and compute dose + dose_matrix_beta = self.apply_s_value( + self.results_fitting, svalues_beta, radiation_type="beta" + ) + dose_matrix_gamma = self.apply_s_value( + self.results_fitting, svalues_gamma, radiation_type="gamma" + ) + + # Sum doses over source organs to get total dose per target organ + total_dose_beta = dose_matrix_beta.sum(axis=1) + total_dose_gamma = dose_matrix_gamma.sum(axis=1) + + dose_df = pandas.DataFrame( + { + "Target organ": total_dose_beta.index, + "AD_beta[Gy/GBq]": total_dose_beta.values, + "AD_gamma[Gy/GBq]": total_dose_gamma.values, + } + ) + + # Apply mass scaling + dose_df = self.perform_mass_scaling(dose_df) + + # Calculate absorbed dose in Gy for injected activity + dose_df["AD_total[Gy/GBq]"] = ( + dose_df["AD_beta[Gy/GBq]"] + dose_df["AD_gamma[Gy/GBq]"] + ) + injected_activity = float(self.config["InjectedActivity"]) + dose_df["AD_beta[Gy]"] = dose_df["AD_beta[Gy/GBq]"] / 1000 * injected_activity + dose_df["AD_gamma[Gy]"] = dose_df["AD_gamma[Gy/GBq]"] / 1000 * injected_activity + dose_df["AD_total[Gy]"] = dose_df["AD_total[Gy/GBq]"] / 1000 * injected_activity + + dose_df = dose_df.reset_index(drop=True) + self.results_dosimetry_organs = dose_df.copy() + self.results_dosimetry_organs = self.results_dosimetry_organs.set_index( + "Target organ" + ) + return dose_df + + def hollow_organ_correction(self, df: pandas.DataFrame) -> pandas.DataFrame: + """Apply hollow organ correction to the dose calculations (electrons only).""" + print("Applying hollow organ correction...") + + pairs = [ + ("Gallbladder Wall", "Gallbladder Contents"), + ("Stomach Wall", "Stomach Contents"), + ("Small Intestine", "Small Intestine"), + ("ULI Wall", "ULI Cont"), + ("LLI Wall", "LLI Cont"), + ("Rectum", "Rectum"), + ("Urinary Bladder Wall", "Urinary Bladder Contents"), + ] + + for target, source in pairs: + if target in df.index and source in df.columns: + df.loc[target, source] *= 2 + + return df + + def redistribute_ROB_into_source_organs_missing( + self, tia_series: pandas.Series + ) -> pandas.Series: + """ + If only Total Body TIA is present, we leave it as is, because according to Olinda it represents the Total Body TIA. + + If organs other than Total Body are present, we redistribute the Total Body TIA into missing source organs as it represents the Remainder of the Body. + + This method redistributes the Remainder TIA into source organs that were not segmented and so represent Remainder of the Body. + """ + if "RemainderOfBody" not in tia_series.index: + return tia_series + + missing_organs_df = self.source_organ_masses.loc[ + self.source_organ_masses.index.isin(self.source_organs_missing) + ] + print(f"Missing organs DataFrame:\n{missing_organs_df.index.tolist()}") + + missing_organs_df = missing_organs_df.drop(index="Total Body") + + mass_source_organs = self.source_organ_masses.loc[ + [org for org in tia_series.index if org != "RemainderOfBody"], "Mass_g" + ].sum() + + mass_total_body = self.config["PatientWeight_g"] + + tia_ROB = tia_series["TIA_s"]["RemainderOfBody"] + + missing_organs_df["TIA_s"] = ( + missing_organs_df["Mass_g"] / (mass_total_body - mass_source_organs) + ) * tia_ROB + + missing_organs_df.loc["Heart Contents", "TIA_s"] = 0 + # print % masses + print( + f"Masses of missing organs (% of total body mass):\n{(missing_organs_df['Mass_g'] / mass_total_body) * 100}" + ) + missing_organs_df = missing_organs_df.rename(columns={"Mass_g": "Volume_CT_mL"}) + + print(f"Redistributed TIA values for missing organs: {missing_organs_df}") + print(f"TIA series before redistribution:\n{tia_series}") + tia_series = tia_series.drop(index="RemainderOfBody") + tia_series = pandas.concat([tia_series, missing_organs_df]) + tia_series["h"] = tia_series["TIA_s"] / 3600 # Convert seconds to hours + + return tia_series + + def apply_s_value(self, tia_df, s_values, radiation_type) -> pandas.DataFrame: + """Multiply S-values by TIA to compute dose matrix for radiation type.""" + print(f"Applying S-values for {radiation_type} radiation...") + # Handle remainder of the body + # Redistribute ROB TIA into missing source organs if needed - approach consistent with MIRDcalc software + if "RemainderOfBody" in tia_df.index: + if "MirdCalc" in self.config["OrganLevel"]["Calculation"].get( + "SValueSource", "" + ): + tia_df = self.redistribute_ROB_into_source_organs_missing(tia_df) + + common_source_organs = tia_df.index.intersection(s_values.columns) + + print( + f"{len(common_source_organs)} source organs: {common_source_organs}" + ) + + if common_source_organs.empty: + raise ValueError( + "No common source organs between TIA and S-value table." + ) + + # Subset both dataframes + tia_series = tia_df.loc[common_source_organs, "TIA_s"] + s_values_subset = s_values[common_source_organs] + + print( + f"Selected source organs for dose calculation: {tia_series.index.tolist()}" + ) + print( + f"Selected target organs for dose calculation: {s_values_subset.index.tolist()}" + ) + + # Multiply S-values by corresponding TIA + dose_df = s_values_subset.multiply(tia_series, axis=1) + + # Apply hollow organ correction for beta dose + dose_df = self.hollow_organ_correction(dose_df) + + # Handle find S-value for remainder of the body - approach consistent with Olinda software + elif "Olinda" in self.config["OrganLevel"]["Calculation"].get( + "SValueSource", "" + ): + common_source_organs = tia_df.index.intersection(s_values.columns) + + print( + f"{len(common_source_organs)} source organs: {common_source_organs}" + ) + + if common_source_organs.empty: + raise ValueError( + "No common source organs between TIA and S-value table." + ) + + # Subset both dataframes + tia_series = tia_df.loc[common_source_organs, "TIA_s"] + s_values_subset = s_values[common_source_organs] + + print( + f"Selected source organs for dose calculation: {tia_series.index.tolist()}" + ) + print( + f"Selected target organs for dose calculation: {s_values_subset.index.tolist()}" + ) + + # Multiply S-values by corresponding TIA + dose_df = s_values_subset.multiply(tia_series, axis=1) + + # ROB + print("Calculating Remainder of Body contributions...") + dose_df["RemainderOfBody"] = 0 + total_body_mass = self.source_organ_masses.loc["Total Body", "Mass_g"] + source_organs = tia_series.index + if radiation_type == "beta": + target_organs = s_values_subset.index.difference( + tia_series.index.difference(["Red Marrow", "Osteogenic Cells"]) + ) + ROB_mass = tia_df.loc["RemainderOfBody", "Volume_CT_mL"] + if radiation_type == "gamma": + target_organs = s_values_subset.index + total_mass_source_organs = self.source_organ_masses.loc[ + self.source_organ_masses.index.intersection(tia_series.index), + "Mass_g", + ].sum() + + ROB_mass = total_body_mass - total_mass_source_organs + + for target_organ in target_organs: + contribution_from_sources = 0 + + for source_organ in source_organs: + if radiation_type == "beta": + if target_organ.split()[0] == source_organ.split()[0]: + continue + if target_organ == "Red Marrow" and source_organ in [ + "Trabecular Bone" + ]: + continue + if target_organ == "Osteogenic Cells" and source_organ in [ + "Cortical Bone", + "Trabecular Bone", + "Red Marrow", + ]: + continue + + if source_organ == "Total Body": + continue + + if target_organ == "Total Body": + continue + + source_organ_mass = self.source_organ_masses.loc[ + source_organ, "Mass_g" + ] + + s_value_source_to_target = s_values.loc[ + target_organ, source_organ + ] + contribution_from_sources += s_value_source_to_target * ( + source_organ_mass / ROB_mass + ) + + if target_organ == "Total Body": + s_value_ROB_to_target = ( + s_values.loc[target_organ, "Total Body"] + # * (total_body_mass / ROB_mass) since it is a target organ, the scaling will happen at mass scaling method, so not to have it twice its not performed here + ) - contribution_from_sources + else: + s_value_ROB_to_target = ( + s_values.loc[target_organ, "Total Body"] + * (total_body_mass / ROB_mass) + ) - contribution_from_sources + + dose_df.at[target_organ, "RemainderOfBody"] = float( + s_value_ROB_to_target * tia_df.loc["RemainderOfBody", "TIA_s"] + ) + else: + # TODO + print("No ROB option selected. Proceeding without ROB handling.") + + return dose_df + + def perform_mass_scaling( + self, + df: pandas.DataFrame, + ) -> pandas.DataFrame: + """Apply mass scaling to absorbed dose calculations based on patient-specific organ masses.""" + print("Performing mass scaling...") + + for organ in df["Target organ"]: + mass_key = ( + "Total Body" if organ in ["Total Body", "RemainderOfBody"] else organ + ) + fit_key = ( + "RemainderOfBody" + if organ in ["Total Body", "RemainderOfBody"] + else organ + ) + + if ( + mass_key in self.target_organ_masses.index + and fit_key in self.results_fitting.index + ): + model_mass = self.target_organ_masses.loc[mass_key, "Mass_g"] + patient_mass = self.results_fitting.loc[fit_key, "Volume_CT_mL"] + + if ( + pandas.notna(patient_mass) + and pandas.notna(model_mass) + and model_mass > 0 + ): + + if "AD_beta[Gy/GBq]" in df.columns: + scaling_factor_beta = model_mass / patient_mass + df.loc[ + df["Target organ"] == organ, "AD_beta[Gy/GBq]" + ] *= scaling_factor_beta + print( + f"[β] {organ}: model={model_mass}, patient={patient_mass}, factor={scaling_factor_beta}" + ) + + if "AD_gamma[Gy/GBq]" in df.columns: + scaling_factor_gamma = (model_mass / patient_mass) ** (2 / 3) + df.loc[ + df["Target organ"] == organ, "AD_gamma[Gy/GBq]" + ] *= scaling_factor_gamma + print( + f"[γ] {organ}: model={model_mass}, patient={patient_mass}, factor={scaling_factor_gamma}" + ) + + return df + + def create_Olinda_file(self, dirname: str, savefile: bool = False) -> None: + """Create .cas file that can be exported to Olinda/EXM.""" + if self.config["Gender"] == "Male": + template_file = "adult_male.cas" + elif self.config["Gender"] == "Female": + template_file = "adult_female.cas" + else: + print( + "Ensure that you correctly wrote patient gender in config file. Olinda supports: Male and Female." + ) + return + + with resource_path( + "pytheranostics.data", f"olinda/templates/human/{template_file}" + ) as template_path: + template = pandas.read_csv(template_path) + + template.columns = ["Data"] + match = re.match(r"([a-zA-Z]+)([0-9]+)", self.config["Radionuclide"]) + letters, numbers = match.groups() + formatted_radionuclide = f"{letters}-{numbers}" + + ind = template[template["Data"] == "[BEGIN NUCLIDES]"].index + template.loc[ind[0] + 1, "Data"] = formatted_radionuclide + "|" + + for organ in self.results_fitting_organs.index: + indices = template[template["Data"].str.contains(organ)].index + + source_organ = template.iloc[indices[0]].str.split("|")[0][0] + mass_phantom = template.iloc[indices[0]].str.split("|")[0][1] + kinetic_data = self.results_fitting_organs.loc[organ]["TIA_h"] + mass_data = round(self.results_fitting_organs.loc[organ]["Volume_CT_mL"], 1) + + # Update the template DataFrame + template.iloc[indices[0]] = ( + f"{source_organ}|{mass_phantom}|{'{:7f}'.format(kinetic_data)}" + ) + + if len(indices) == 2: + template.iloc[indices[1]] = ( + f"{source_organ}|{'{:7f}'.format(kinetic_data)}" + ) + elif len(indices) == 3: + template.iloc[indices[1]] = f"{source_organ}|{mass_data}" + template.iloc[indices[2]] = ( + f"{source_organ}|{'{:7f}'.format(kinetic_data)}" + ) + else: + print("Double-check where the organ appears in the template.") + + template = template.replace( + "TARGET_ORGAN_MASSES_ARE_FROM_USER_INPUT|FALSE", + "TARGET_ORGAN_MASSES_ARE_FROM_USER_INPUT|TRUE", + ) + + template.columns = [ + "Saved on " + + datetime.datetime.now().strftime("%m.%d.%Y") + + " at " + + datetime.datetime.now().strftime("%H:%M:%S") + ] + + if savefile: + if not path.exists(dirname): + makedirs(dirname) + + template.to_csv( + str(dirname) + "/" + f"{self.config['PatientID']}.cas", index=False + ) + + def read_results(self, olinda_results_path: str) -> None: + """Read results from external software based on the output format specified in the configuration.""" + if self.config["OutputFormat"] == "Olinda": + self.read_Olinda_results(olinda_results_path) + + def read_Olinda_results(self, olinda_results_path: str) -> None: + """Read .txt results file from Olinda.""" + if not olinda_results_path.endswith(".txt"): + print( + "Please export result from Olinda in .txt file." + ) # TODO: Add .csv extension results + return + + data = [] + with open(olinda_results_path, "r") as file: + + extract_lines = False + for line in file: + stripped_line = line.strip() + if stripped_line.startswith( + "Target Organ,Alpha,Beta,Gamma,Total,ICRP-103 ED" + ): + extract_lines = True + elif stripped_line.startswith("Target Organ Name,Mass [g],"): + extract_lines = False + if extract_lines: + if stripped_line.startswith("Effective Dose"): + stripped_line = stripped_line.replace( + "Effective Dose", "Effective Dose,,,," + ) + stripped_line = stripped_line.rstrip(",") + data.append(stripped_line.split(",")) + + if not data: + print("No relevant data found in the file.") + return + + df_ad = pandas.DataFrame(data[1:], columns=data[0]) + df_ad = df_ad.set_index("Target Organ") + df_ad = df_ad.dropna(axis=0) + df_ad["Total"] = pandas.to_numeric(df_ad["Total"], errors="coerce") + df_ad["Total"].fillna(0, inplace=True) + df_ad["AD[Gy]"] = df_ad["Total"] * float(self.config["InjectedActivity"]) / 1000 + df_ad = df_ad.rename(columns={"Total": "AD[Gy/GBq]"}) + self.results_dosimetry_organs = df_ad + + def compute_dose(self): + """Compute Time Integrated Activity.""" + self.compute_tia() diff --git a/pytheranostics/dosimetry/voxel_s_dosimetry.py b/pytheranostics/dosimetry/voxel_s_dosimetry.py new file mode 100644 index 0000000..3dd719e --- /dev/null +++ b/pytheranostics/dosimetry/voxel_s_dosimetry.py @@ -0,0 +1,271 @@ +"""Module for voxel-level dosimetry calculations.""" + +import os +import shutil +from typing import Any, Dict, Optional + +import numpy +import pandas +import SimpleITK +from pandas import DataFrame + +from pytheranostics.dosimetry.base_dosimetry import BaseDosimetry +from pytheranostics.dosimetry.dvk import DoseVoxelKernel +from pytheranostics.fits.fits import get_exponential +from pytheranostics.imaging_ds.longitudinal_study import LongitudinalStudy +from pytheranostics.imaging_tools.tools import itk_image_from_array, resample_to_target +from pytheranostics.shared.resources import resource_path + + +class VoxelSDosimetry(BaseDosimetry): + """Voxel S Dosimetry class. + + Computes parameters of fit for time activity curves at the region (organ/lesion) level, + and apply them at the voxel level for voxels belonging to user-defined regions. + """ + + def __init__( + self, + config: Dict[str, Any], + nm_data: LongitudinalStudy, + ct_data: LongitudinalStudy, + clinical_data: Optional[DataFrame] = None, + ) -> None: + super().__init__(config, nm_data, ct_data, clinical_data) + + # Time-integrated activity and dose maps at the voxel level. + self.tia_map: LongitudinalStudy = LongitudinalStudy( + images={}, meta={}, modality="NM" + ) + self.dose_map: LongitudinalStudy = LongitudinalStudy( + images={}, meta={}, modality="DOSE" + ) + + self.toMBqs = 3600 # Convert MBqh toMBqs + + def compute_voxel_tia(self) -> None: + """Compute the Time Integrated Activity (TIA) for each voxel in specified regions. + + This method uses the fit parameters for each region to compute the TIA for + each voxel within those regions. It handles different regions appropriately, + ensuring no double-counting or overlapping of regions. + + The result of this operation is tia_map that is a longitudinal study + + Returns + ------- + None + + Raises + ------ + AssertionError + If overlapping structures are found when adding regions to calculate voxel-TIA. + """ + ref_time_id = int(self.config["ReferenceTimePoint"]) + tia_map = numpy.zeros_like( + self.nm_data.array_at(time_id=ref_time_id), dtype=numpy.float64 + ) + + # Check we're not having overlapping regions: + masks = numpy.zeros_like(tia_map, dtype=numpy.int8) + + for region, region_data in self.results.iterrows(): + + if region == "WholeBody": + continue # We do not want to double count voxels! + + print(f"Computing Voxel-S dose for {region} ...") + + region_mask = self.nm_data.masks[ref_time_id][region] + masks += region_mask + if numpy.max(masks) > 1: + raise AssertionError( + f"Overlapping structures found when {region} was added to calculate voxel-TIA" + ) + + act_map_at_ref = ( + self.nm_data.array_of_activity_at(time_id=ref_time_id, region=region) + * self.toMBq + ) # MBq + region_tia = region_data["TIA_MBq_h"] + + region_fit_params = region_data["Fit_params"] # fit params + exp_order = self.config["rois"][region]["fit_order"] + region_fit, _, _ = get_exponential( + order=exp_order, param_init=None, decayconst=1.0 + ) # Decay-constant not used here. + + ref_time = region_data["Time_hr"][ + self.config["ReferenceTimePoint"] + ] # In hours, post injection. + f_to = region_fit(ref_time, *tuple(region_fit_params)) + + tia_map += ( + region_mask.astype(numpy.float64) * region_tia * act_map_at_ref / f_to + ) # MBq_h + + # Create ITK Image Object and embed it into a LongitudinalStudy. #TODO: modularize, repeated code downwards. + tia_image = itk_image_from_array( + array=numpy.transpose(tia_map, axes=(2, 0, 1)), + ref_image=self.nm_data.images[ref_time_id], + ) + self.tia_map = LongitudinalStudy( + images={0: tia_image}, + meta={0: self.nm_data.meta[ref_time_id]}, + modality="NM", + ) + self.tia_map.masks[0] = self.nm_data.masks[0].copy() # Copy masks. + + return None + + def apply_voxel_s(self) -> None: + """Apply convolution over TIA map.""" + ref_time_id = self.config["ReferenceTimePoint"] + nm_voxel_mm = self.nm_data.images[ref_time_id].GetSpacing()[0] + + dose_kernel = DoseVoxelKernel( + isotope=self.nm_data.meta[0].Radionuclide, voxel_size_mm=nm_voxel_mm + ) + + # Resample CT to NM (Default using linear interpolator) + resampled_ct = resample_to_target( + source_img=self.ct_data.images[ref_time_id], + target_img=self.nm_data.images[ref_time_id], + ) + + dose_map_array = dose_kernel.tia_to_dose( + tia_mbq_s=self.tia_map.array_at(0) * self.toMBqs, + ct=( + numpy.transpose( + SimpleITK.GetArrayFromImage(resampled_ct), axes=(1, 2, 0) + ) + if self.config["ScaleDoseByDensity"] + else None + ), + ) + + # Create ITK Image Object and embed it into a LongitudinalStudy + # Clear dose outside patient body: + dose_map_array *= self.nm_data.masks[ref_time_id]["WholeBody"] + + self.dose_map = LongitudinalStudy( + images={ + 0: itk_image_from_array( + array=numpy.transpose(dose_map_array, axes=(2, 0, 1)), + ref_image=self.nm_data.images[ref_time_id], + ) + }, + meta={0: self.nm_data.meta[ref_time_id]}, + ) + + self.dose_map.masks[0] = self.nm_data.masks[0].copy() + + return None + + def run_MC(self) -> None: # TODO: finish the code!!!!! + """Run MC.""" + raise NotImplementedError("MC is not implemmented yet.") + n_cpu = self.config["#CPU"] + n_primaries = self.config["#primaries"] + output_dir = self.config["results_path"] + + # ============================================================================= + # Split Simulations + # ============================================================================= + n_primaries_per_mac = int(n_primaries / n_cpu) + + with resource_path( + "pytheranostics.data", "monte_carlo/main_template.mac" + ) as template_path: + with template_path.open("r", encoding="utf-8") as mac_file: + filedata = mac_file.read() + + for i in range(0, n_cpu): + new_mac = filedata + + new_mac = new_mac.replace("distrib-SPLIT.mhd", f"distrib_SPLIT_{i+1}.mhd") + new_mac = new_mac.replace("stat-SPLIT.txt", f"stat__SPLIT_{i+1}.txt") + new_mac = new_mac.replace("XXX", str(n_primaries_per_mac)) + + with open(f"{output_dir}/main_normalized_{i+1}.mac", "w") as output_mac: + output_mac.write(new_mac) + + # ============================================================================= + # Create Folders with Data + # ============================================================================= + os.makedirs(os.path.join(output_dir, "data"), exist_ok=True) + os.makedirs(os.path.join(output_dir, "output"), exist_ok=True) + + with resource_path("pytheranostics.data", "monte_carlo/data") as folder_path: + for entry in folder_path.iterdir(): + if entry.is_file(): + shutil.copy( + entry, + os.path.join(output_dir, "data", entry.name), + ) + + # TODO: Below is still work in progress + + # total_acc_A = np.sum(np.sum(np.sum(self.tia_map[0]))) + # self.source_normalized = self.TIAp / self.total_acc_A + + ref_time_id = self.config["ReferenceTimePoint"] + self.tia_map.save_image_to_mhd_at( + time_id=0, + out_path=os.path.join(output_dir, "data"), + name="Source_normalized", + ) + self.ct_data.save_image_to_mhd_at( + time_id=ref_time_id, out_path=os.path.join(output_dir, "data"), name="CT" + ) + + with open( + os.path.join(os.path.join(output_dir, "output"), "TotalAccA.txt"), "w" + ) as fileID: + fileID.write("%.2f" % self.total_acc_A) + + # ============================================================================= + # Run Monte Carlo + # ============================================================================= + + return None + + def compute_dose(self) -> None: + """Compute dose by performing the following steps. + + Compute TIA at the region level. + Get parameters of fit from region and compute TIA at the voxel level. + Convolve TIA map with Dose kernel and (optional) scale with CT density. + """ + self.compute_tia() + self.compute_voxel_tia() + if self.config["Method"] == "Voxel-S-value": + self.apply_voxel_s() + elif self.config["Method"] == "Monte-Carlo": + self.run_MC() + else: + raise ValueError( + f"Dosimetry Method {self.config['Method']} not implemented." + ) + + # Generate DataFrame. + dose_Gy = [] + dose_Gy_GBq = [] + for region in self.results.index: + + tmp_Gy = self.dose_map.average_of(region=region, time_id=0) / 1000 + + dose_Gy.append(tmp_Gy) + dose_Gy_GBq.append(tmp_Gy / (float(self.config["InjectedActivity"]) / 1000)) + + self.df_ad = pandas.DataFrame( + {"AD[Gy]": dose_Gy, "AD[Gy/GBq]": dose_Gy_GBq}, + index=[region for region in self.results.index], + ) + + # Save dose-map to .nii -> use integer version + self.dose_map.save_image_to_nii_at( + time_id=0, out_path=self.db_dir, name="DoseMap.nii.gz" + ) + + return None diff --git a/pytheranostics/fits/__init__.py b/pytheranostics/fits/__init__.py new file mode 100644 index 0000000..3731650 --- /dev/null +++ b/pytheranostics/fits/__init__.py @@ -0,0 +1 @@ +"""PyTheranostics package.""" diff --git a/pytheranostics/fits/fits.py b/pytheranostics/fits/fits.py new file mode 100644 index 0000000..e8874dc --- /dev/null +++ b/pytheranostics/fits/fits.py @@ -0,0 +1,236 @@ +"""Curve-fitting utilities built on top of lmfit.""" + +from typing import Any, Callable, Dict, Optional, Tuple + +import lmfit +import numpy +from lmfit import Model + +from pytheranostics.fits.functions import ( + biexp_fun, + biexp_fun_uptake, + monoexp_fun, + triexp_fun, +) + + +def exponential_fit_lmfit( + x_data: numpy.ndarray, + y_data: numpy.ndarray, + num_exponentials: int = 1, + sigma: Optional[numpy.ndarray] = None, + fixed_params: Optional[Dict[str, float]] = None, + bounds: Optional[Dict[str, Tuple[float, float]]] = None, + params_init: Optional[Dict[str, float]] = None, + with_uptake: bool = False, + washout_ratio: Optional[int] = None, +) -> Tuple[lmfit.model.ModelResult, Callable]: + """Fit data to a sum of exponentials with flexible parameter fixing using lmfit. + + This function fits time-activity data to a sum of exponential functions using + the lmfit package. It supports mono-, bi-, and tri-exponential fits with + optional parameter fixing and constraints. + + Parameters + ---------- + x_data : array-like + Independent variable data points. + y_data : array-like + Dependent variable data points. + sigma : array-like, optional + Standard deviation of the data points for weighted fitting. + num_exponentials : int + Number of exponential terms (1, 2, or 3). + fixed_params : dict, optional + Dictionary of parameters to fix with their values. + Keys are parameter names ('A1', 'A2', 'B1', etc.), by default None. + bounds : dict, optional + Dictionary of parameter boundaries. + Keys are parameter names, values are (min, max) tuples, by default None. + params_init : dict, optional + Dictionary of initial parameter values. + Keys are parameter names, by default None. + with_uptake : bool, optional + Whether to apply uptake phase constraints, by default False. + + Returns + ------- + result : lmfit.model.ModelResult + Object containing the fit results and statistics. + fitted_model : callable + The fitted model function that can be used for predictions. + + Raises + ------ + ValueError + If num_exponentials is not 1, 2, or 3. + If required parameters are missing for uptake constraints. + + Notes + ----- + For uptake phase constraints: + - For bi-exponential: B1 = -A1 + - For tri-exponential: C1 = -(A1 + B1) + """ + if num_exponentials not in [1, 2, 3]: + raise ValueError( + f"num_exponentials must be 1, 2, or 3., found {num_exponentials}" + ) + + if fixed_params is None: + fixed_params = {} + + # Define the model function + def model_func(x, **params): + y = numpy.zeros_like(x) + terms = ["A", "B", "C"][:num_exponentials] + for term in terms: + A1 = params.get(f"{term}1", 0) + A2 = params.get(f"{term}2", 0) + y += A1 * numpy.exp(-A2 * x) + return y + + # Create a Model using lmfit + model = Model(model_func, independent_vars=["x"]) + + # Create parameters with initial guesses + params = lmfit.Parameters() + terms = ["A", "B", "C"][:num_exponentials] + + for term in terms: + # Amplitude parameter + amp_name = f"{term}1" + # Check if parameter will have an expression + will_have_expr = False + if num_exponentials == 2 and with_uptake and amp_name == "B1": + will_have_expr = True + if num_exponentials == 3 and with_uptake and amp_name == "C1": + will_have_expr = True + if num_exponentials == 2 and washout_ratio and amp_name == "B2": + will_have_expr = True + + if amp_name in fixed_params: + params.add(amp_name, value=fixed_params[amp_name], vary=False) + elif will_have_expr: + # Add parameter that will have an expression + params.add(amp_name, value=0, vary=False) + else: + min_b = -numpy.inf # Allow negative amplitudes + max_b = numpy.inf + init_param = (max(y_data) - min(y_data)) / num_exponentials + + if bounds is not None and amp_name in bounds: + min_b, max_b = bounds[amp_name] + if params_init is not None and amp_name in params_init: + init_param = params_init[amp_name] + + params.add(amp_name, value=init_param, min=min_b, max=max_b) + + # Exponent parameter + exp_name = f"{term}2" + if exp_name in fixed_params: + params.add(exp_name, value=fixed_params[exp_name], vary=False) + else: + min_b = 0 # Exponents should be positive for decay + max_b = numpy.inf + init_param = 1.0 + + if bounds is not None and exp_name in bounds: + min_b, max_b = bounds[exp_name] + if params_init is not None and exp_name in params_init: + init_param = params_init[exp_name] + + params.add(exp_name, value=init_param, min=min_b, max=max_b) + + # Apply constraints if specified + if num_exponentials == 2 and with_uptake: + if "B1" in params and "A1" in params: + print("Adding constraint for uptake: B1 = -A1") + params["B1"].set(expr="-A1") + else: + raise ValueError( + "Parameters 'A1' and 'B1' must be present to apply the constraint 'B1 = -A1'." + ) + + if num_exponentials == 3 and with_uptake: + if "C1" in params and "A1" in params and "B1" in params: + print("Adding constraint for uptake: C1 = -(A1 + B1)") + params["C1"].set(expr="-(A1 + B1)") + else: + raise ValueError( + "Parameters 'A1', 'B1', and 'C1' must be present to apply the constraint 'C1 = -(A1 + B1)'." + ) + + if num_exponentials == 2 and washout_ratio is not None: + if "B2" in params and "A2" in params: + print( + f"Applying constant ratio between decay constants of washout phases: B2 = {washout_ratio} * A2" + ) + params["B2"].set(expr=f"{washout_ratio} * A2") + else: + raise ValueError( + "Parameters 'A2' and 'B2' must be present to apply the constraint 'B2 = washout_ratio * A2'." + ) + + print(y_data, params) + # Perform the fit + result = model.fit( + y_data, params, x=x_data, weights=None if sigma is None else 1.0 / sigma + ) + + # Define the fitted model function + def fitted_model(x): + return model_func(x, **result.params.valuesdict()) + + return result, fitted_model + + +def calculate_r_squared( + time: numpy.ndarray, activity: numpy.ndarray, popt: numpy.ndarray, func: Callable +) -> Tuple[float, numpy.ndarray]: + """Calculate r-squared and residuals between the fit and data points.""" + residuals = activity - func(time, *popt) + + ss_res = numpy.sum(residuals**2) + ss_tot = numpy.sum((activity - numpy.mean(activity)) ** 2) + r_squared = 1 - (ss_res / ss_tot) + + return r_squared, residuals + + +def get_exponential( + order: int, param_init: Optional[Tuple[float, ...]], decayconst: float +) -> Tuple[Callable, Tuple[float, ...], Optional[Tuple[Any, ...]]]: + """Retrieve an exponential model, default parameters, and bounds.""" + # Default initial parameters: + default_initial = { + 1: (1, 1), + 2: (1, 1, 1, 0.1), + -2: (1, 1, 1), + 3: (1, 1, 1, 1, 1, 1), + } + + # Bounds: It can't decay slower than physical decay! + bounds = { + 1: ([0, decayconst], numpy.inf), + 2: ([0, decayconst, 0, decayconst], numpy.inf), + -2: ([0, decayconst, decayconst], [numpy.inf, numpy.inf, numpy.inf]), + 3: (-numpy.inf, numpy.inf), + } + + if order == 1: + func = monoexp_fun + elif order == 2: + func = biexp_fun + elif order == -2: + func = biexp_fun_uptake + elif order == 3: + func = triexp_fun + else: + NotImplementedError("Function not implemented.") + + return ( + func, + default_initial[order] if param_init is None else param_init, + bounds[order], + ) diff --git a/pytheranostics/fits/functions.py b/pytheranostics/fits/functions.py new file mode 100644 index 0000000..513b849 --- /dev/null +++ b/pytheranostics/fits/functions.py @@ -0,0 +1,44 @@ +"""Elementary exponential functions used by the fitting module.""" + +import math + +import numpy +from numpy import exp + + +# Function definitions +def monoexp_fun(x: numpy.ndarray, a: float, b: float) -> numpy.ndarray: + """Return a single exponential evaluated at ``x``.""" + return a * exp(-b * x) + + +def biexp_fun( + x: numpy.ndarray, a: float, b: float, c: float, d: float +) -> numpy.ndarray: + """Return the sum of two exponential decay terms.""" + return a * exp(-b * x) + c * exp(-d * x) + + +def biexp_fun_uptake(x: numpy.ndarray, a: float, b: float, c: float) -> numpy.ndarray: + """Return the uptake-style biexponential curve.""" + return a * exp(-b * x) - a * exp(-c * x) + + +def triexp_fun( + x: numpy.ndarray, a: float, b: float, c: float, d: float, f: float +) -> numpy.ndarray: + """Return the tri-exponential washout model.""" + return a * exp(-b * x) + c * exp(-d * x) - (a + c) * exp(-f * x) + + +def find_a_initial( + f: numpy.ndarray, b: numpy.ndarray, t: numpy.ndarray +) -> numpy.ndarray: + """Estimate the initial amplitude given decay constants and time samples.""" + return f * exp(b * t) + + +# TODO: Review Hanscheid inputs/outputs. +def Hanscheid(a, t): + """Compute the Hanscheid approximation for cumulated activity.""" + return a * ((2 * t) / (math.log(2))) diff --git a/pytheranostics/imaging_ds/__init__.py b/pytheranostics/imaging_ds/__init__.py new file mode 100644 index 0000000..d00f93c --- /dev/null +++ b/pytheranostics/imaging_ds/__init__.py @@ -0,0 +1,21 @@ +"""Imaging dataset utilities for medical imaging analysis.""" + +from .cycle_loader import ( + create_studies_with_masks, + extract_injection_from_first_tp_spect, + list_cycle_timepoints, + prepare_cycle_inputs, +) +from .longitudinal_study import LongitudinalStudy +from .mapping_summary import summarize_used_mappings +from .metadata import ImagingMetadata + +__all__ = [ + "LongitudinalStudy", + "ImagingMetadata", + "prepare_cycle_inputs", + "list_cycle_timepoints", + "extract_injection_from_first_tp_spect", + "create_studies_with_masks", + "summarize_used_mappings", +] diff --git a/pytheranostics/imaging_ds/cycle_loader.py b/pytheranostics/imaging_ds/cycle_loader.py new file mode 100644 index 0000000..90d8a36 --- /dev/null +++ b/pytheranostics/imaging_ds/cycle_loader.py @@ -0,0 +1,478 @@ +""" +Cycle data loading utilities for PyTheranostics. + +Load organized DICOM data (CT, SPECT, RTSTRUCT) for dosimetry processing +from the folder structure produced by the DICOM receiver or manual organization. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Dict, List, Optional, Tuple, Union + +import pydicom +import SimpleITK + +from pytheranostics.imaging_ds.longitudinal_study import LongitudinalStudy +from pytheranostics.imaging_tools.tools import load_and_resample_RT_to_target + + +def _extract_hhmmss(tm: Optional[str]) -> Optional[str]: + """Convert a DICOM TM string to HHMMSS (6 digits).""" + if not tm: + return None + digits = "".join(ch for ch in str(tm) if ch.isdigit()) + if not digits: + return None + return (digits + "000000")[:6] + + +def list_cycle_timepoints( + storage_root: str | Path, patient_id: str, cycle_no: int +) -> Tuple[List[Optional[Path]], List[Optional[Path]], List[Optional[Path]]]: + """List CT, SPECT, and RTSTRUCT paths for a given patient cycle. + + Assumes the layout: storage_root/PatientID/CycleX/tpY/ with RTSTRUCT + under CT/RTstruct. Returns three lists aligned by timepoint index. + + Parameters + ---------- + storage_root : str | Path + Base directory where organized data lives. + patient_id : str + Patient identifier. + cycle_no : int + Cycle number (1-based). + + Returns + ------- + tuple[list[Path | None], list[Path | None], list[Path | None]] + Lists of CT directories, SPECT directories, and RTSTRUCT files (one per timepoint). + """ + root = Path(storage_root) + cycle_dir = root / patient_id / f"Cycle{int(cycle_no)}" + if not cycle_dir.exists(): + raise FileNotFoundError(f"Cycle directory not found: {cycle_dir}") + + # Sort tp folders by numeric suffix + tp_dirs: List[Tuple[int, Path]] = [] + for p in cycle_dir.iterdir(): + if p.is_dir() and p.name.startswith("tp"): + m = re.search(r"(\d+)$", p.name) + idx = int(m.group(1)) if m else 0 + tp_dirs.append((idx, p)) + tp_dirs.sort(key=lambda x: x[0]) + + ct_paths: List[Optional[Path]] = [] + spect_paths: List[Optional[Path]] = [] + rtstruct_files: List[Optional[Path]] = [] + + for _, tp in tp_dirs: + ct_dir = tp / "CT" + spect_dir = tp / "SPECT" + # CT path (directory) if present + ct_paths.append(ct_dir if ct_dir.exists() else None) + # SPECT path (directory) if present + spect_paths.append(spect_dir if spect_dir.exists() else None) + # RTSTRUCT file: pick first .dcm in CT/RTstruct if present + rtstruct_dir = ct_dir / "RTstruct" + rt_file: Optional[Path] = None + if rtstruct_dir.exists(): + for f in sorted(rtstruct_dir.glob("*.dcm")): + # Trust folder name; otherwise verify Modality + try: + ds = pydicom.dcmread(str(f), stop_before_pixels=True, force=True) + if getattr(ds, "Modality", "") == "RTSTRUCT": + rt_file = f + break + except Exception: + continue + rtstruct_files.append(rt_file) + + return ct_paths, spect_paths, rtstruct_files + + +def extract_injection_from_first_tp_spect( + storage_root: str | Path, patient_id: str, cycle_no: int +) -> Dict[str, Optional[str | int]]: + """Extract injection info from the first time point SPECT DICOM in a cycle. + + Returns a dictionary with keys: InjectionDate (YYYYMMDD), InjectionTime (HHMMSS), + InjectedActivity (Bq, int) and PatientWeight_g (int). Values may be None/empty + if not present in the DICOM headers. + + Parameters + ---------- + storage_root : str | Path + Base directory where organized data lives. + patient_id : str + Patient identifier. + cycle_no : int + Cycle number (1-based). + + Returns + ------- + dict[str, str | int | None] + Dictionary with InjectionDate, InjectionTime, InjectedActivity, PatientWeight_g. + """ + _, spect_paths, _ = list_cycle_timepoints(storage_root, patient_id, cycle_no) + if not spect_paths: + raise RuntimeError("No time points found for SPECT") + + tp1_spect = spect_paths[0] + if tp1_spect is None or not tp1_spect.exists(): + raise FileNotFoundError("First time point SPECT directory not found") + + # Find a DICOM file + dcm_file = next(iter(sorted(tp1_spect.glob("*.dcm"))), None) + if dcm_file is None: + raise FileNotFoundError("No DICOM files found in first SPECT time point") + + ds = pydicom.dcmread(str(dcm_file), stop_before_pixels=True, force=True) + + # Defaults from study if radiopharm sequence is missing + inj_date = getattr(ds, "StudyDate", None) + inj_time = _extract_hhmmss(getattr(ds, "StudyTime", None)) + injected_activity: Optional[int] = None + + if hasattr(ds, "RadiopharmaceuticalInformationSequence"): + try: + rp = ds.RadiopharmaceuticalInformationSequence[0] + # Try RadiopharmaceuticalStartDateTime first (combined date/time) + rp_datetime = getattr(rp, "RadiopharmaceuticalStartDateTime", None) + if rp_datetime: + # Format: YYYYMMDDHHMMSS.FFFFFF or YYYYMMDDHHMMSS + dt_str = str(rp_datetime) + if len(dt_str) >= 14: + inj_date = dt_str[:8] # YYYYMMDD + inj_time = dt_str[8:14] # HHMMSS + else: + # Fall back to separate Date/Time fields + inj_date = getattr(rp, "RadiopharmaceuticalStartDate", inj_date) + inj_time = _extract_hhmmss( + getattr(rp, "RadiopharmaceuticalStartTime", inj_time) + ) + + dose = getattr(rp, "RadionuclideTotalDose", None) + if dose is not None: + try: + injected_activity = int(round(float(dose))) # Bq + except Exception: + pass + except Exception: + pass + + # Patient weight in grams + weight_g: Optional[int] = None + pw = getattr(ds, "PatientWeight", None) # kg + if pw is not None: + try: + weight_g = int(round(float(pw) * 1000.0)) + except Exception: + pass + height_cm: Optional[int] = None + pw = getattr(ds, "PatientSize", None) # cm + if pw is not None: + try: + height_cm = int(round(float(pw) * 100.0)) + except Exception: + pass + return { + "InjectionDate": inj_date or "", + "InjectionTime": inj_time or "", + "InjectedActivity": injected_activity, # Bq (int) or None + "PatientWeight_g": weight_g, + "PatientHeight_cm": height_cm, + } + + +def prepare_cycle_inputs( + storage_root: str | Path, patient_id: str, cycle_no: int +) -> Tuple[ + List[Optional[Path]], + List[Optional[Path]], + List[Optional[Path]], + Dict[str, Optional[str | int]], +]: + """Prepare inputs for longitudinal processing for a given cycle. + + One-liner to load CT, SPECT, RTSTRUCT paths and injection metadata for dosimetry. + + Parameters + ---------- + storage_root : str | Path + Base directory where organized data lives. + patient_id : str + Patient identifier. + cycle_no : int + Cycle number (1-based). + + Returns + ------- + tuple + (ct_paths, spect_paths, rtstruct_files, injection_info) where injection_info + is a dict with InjectionDate, InjectionTime, InjectedActivity, PatientWeight_g. + """ + ct_paths, spect_paths, rtstruct_files = list_cycle_timepoints( + storage_root, patient_id, cycle_no + ) + inj = extract_injection_from_first_tp_spect(storage_root, patient_id, cycle_no) + return ct_paths, spect_paths, rtstruct_files, inj + + +# --- New high-level orchestration API --------------------------------------------------------- + + +def _canonical_mask_name(name: str) -> str: + """Map RTSTRUCT ROI names to canonical pyTheranostics mask names. + + Best-effort normalization used for auto mapping. Keeps unknown names as-is. + """ + # Strip modality suffixes often used in notebooks (e.g., _m for CT-based, _a for activity) + base = name + if base.endswith("_m") or base.endswith("_a"): + base = base[:-2] + + # Common synonyms/abbreviations + replacements = { + "Kidney_L": "Kidney_Left", + "Kidney_R": "Kidney_Right", + "Parotid_L": "ParotidGland_Left", + "Parotid_R": "ParotidGland_Right", + "Submandibular_L": "SubmandibularGland_Left", + "Submandibular_R": "SubmandibularGland_Right", + "WBCT": "WholeBody", + } + return replacements.get(base, base) + + +def _build_auto_mapping(mask_keys: List[str]) -> Dict[str, str]: + """Build a mapping dict from available mask keys to canonical names.""" + mapping: Dict[str, str] = {} + for key in mask_keys: + mapping[key] = _canonical_mask_name(key) + return mapping + + +def create_studies_with_masks( + storage_root: str | Path, + patient_id: str, + cycle_no: int, + *, + calibration_factor: Optional[float] = None, + parallel: bool = True, + max_workers: Optional[int] = None, + auto_map: bool = False, + ct_mask_mapping: Optional[Dict[str, str]] = None, + spect_mask_mapping: Optional[Dict[str, str]] = None, + mapping_config: Optional[Union[str, Path, Dict[str, Dict[str, str]]]] = None, +) -> Tuple[ + LongitudinalStudy, + LongitudinalStudy, + Dict[str, Optional[str | int]], + Dict[int, Dict[str, str]], +]: + """Create longitudinal studies and load/resample masks in one pass. + + This function reads all DICOM data once to build CT and SPECT longitudinal studies, + extracts injection information, and loads + resamples RTSTRUCT masks for each timepoint. + By default, masks are imported under their original ROI names (no validation or + canonical mapping). You can optionally pass explicit mappings or enable auto_map + to normalize ROI names at load time. + + Parameters + ---------- + storage_root : str | Path + Base directory where organized data lives. + patient_id : str + Patient identifier. + cycle_no : int + Cycle number (1-based). + calibration_factor : float, optional + Optional SPECT calibration factor (Bq per count/sec) applied during image load. + parallel : bool + Load each timepoint in parallel when possible, defaults True. + max_workers : int, optional + Max threads for loading, defaults to sensible number when None. + auto_map : bool + If True, infer and APPLY mask mappings from available ROI names (e.g., Kidney_L -> Kidney_Left). + Defaults to False to import masks "as-is" and normalize later. + ct_mask_mapping : dict, optional + Explicit mapping for CT masks; overrides auto mapping if provided. + spect_mask_mapping : dict, optional + Explicit mapping for SPECT masks; overrides auto mapping if provided. + mapping_config : str | Path | dict, optional + Either: + - Path to a JSON file containing 'ct_mappings' and 'spect_mappings' keys + - A dictionary with 'ct_mappings' and 'spect_mappings' keys + If provided, loads mappings from this config. Individual ct_mask_mapping and + spect_mask_mapping parameters override keys from the config. + + Returns + ------- + (longCT, longSPECT, injection_info, used_mappings) + - longCT: LongitudinalStudy of CT timepoints + - longSPECT: LongitudinalStudy of SPECT timepoints + - injection_info: Dict with InjectionDate, InjectionTime, InjectedActivity, PatientWeight_g + - used_mappings: Dict[time_id, mapping_summary] of the mapping applied per timepoint + """ + # Load mappings from config if provided + config_ct_mapping = None + config_spect_mapping = None + + if mapping_config is not None: + if isinstance(mapping_config, dict): + # Direct dict provided + config_ct_mapping = mapping_config.get("ct_mappings", {}) + config_spect_mapping = mapping_config.get("spect_mappings", {}) + else: + # Path to JSON file + loaded = LongitudinalStudy.load_mappings_from_json(mapping_config) + config_ct_mapping = loaded.get("ct_mappings", {}) + config_spect_mapping = loaded.get("spect_mappings", {}) + + # Individual parameters override config + final_ct_mapping = ( + ct_mask_mapping if ct_mask_mapping is not None else config_ct_mapping + ) + final_spect_mapping = ( + spect_mask_mapping if spect_mask_mapping is not None else config_spect_mapping + ) + + # 1) Discover paths and injection metadata + ct_paths, spect_paths, rtstruct_files, inj = prepare_cycle_inputs( + storage_root, patient_id, cycle_no + ) + + # 2) Build longitudinal studies from DICOM once + ct_dirs = [str(p) for p in ct_paths if p is not None] + spect_dirs = [str(p) for p in spect_paths if p is not None] + + longCT = LongitudinalStudy.from_dicom( + dicom_dirs=ct_dirs, + modality="CT", + parallel=parallel, + max_workers=max_workers, + ) + longSPECT = LongitudinalStudy.from_dicom( + dicom_dirs=spect_dirs, + modality="Lu177_SPECT", + calibration_factor=calibration_factor, + parallel=parallel, + max_workers=max_workers, + ) + + # 3) Load and resample masks per timepoint, add to both studies + # Track mappings separately by study origin + used_mappings: Dict[int, Dict[str, Dict[str, str]]] = {} + for time_id, rt_file in enumerate(rtstruct_files): + ct_dir = ct_paths[time_id] + if ct_dir is None or rt_file is None: + continue + + # Use the SPECT image at this timepoint as target for NM masks + target_img: Optional[SimpleITK.Image] = longSPECT.images.get(time_id) + if target_img is None: + # No SPECT for this timepoint; still allow CT masks + # Build NM masks only if target available + target_img = next(iter(longSPECT.images.values()), None) + + ct_masks, nm_masks = load_and_resample_RT_to_target( + ref_dicom_ct_dir=str(ct_dir), + rt_struct_file=str(rt_file), + target_img=target_img, + ) + + # Decide whether to apply mappings now or import raw names + apply_ct_mapping = (final_ct_mapping is not None) or auto_map + apply_spect_mapping = (final_spect_mapping is not None) or auto_map + + def _is_valid_target(name: str) -> bool: + if name in LongitudinalStudy._VALID_ORGAN_NAMES: + return True + return re.match(r"^Lesion_([1-9]\d*)$", name) is not None + + # --- CT masks + if apply_ct_mapping: + ct_map_valid: Dict[str, str] = {} + ct_raw_keys: List[str] = [] + if final_ct_mapping is not None: + for k in ct_masks.keys(): + dst = final_ct_mapping.get(k) + if dst is not None and _is_valid_target(dst): + ct_map_valid[k] = dst + else: + ct_raw_keys.append(k) + else: + # auto map + for k in ct_masks.keys(): + dst = _canonical_mask_name(k) + if _is_valid_target(dst): + ct_map_valid[k] = dst + else: + ct_raw_keys.append(k) + + if ct_map_valid: + longCT.add_masks_to_time_point( + time_id=time_id, masks=ct_masks, mask_mapping=ct_map_valid + ) + if ct_raw_keys: + longCT.add_raw_masks_to_time_point( + time_id=time_id, + masks={k: ct_masks[k] for k in ct_raw_keys}, + ) + # Track used mapping (identity for raw keys) + ct_map = { + **{k: v for k, v in ct_map_valid.items()}, + **{k: k for k in ct_raw_keys}, + } + else: + # Import as-is + longCT.add_raw_masks_to_time_point(time_id=time_id, masks=ct_masks) + ct_map = {k: k for k in ct_masks.keys()} + + if target_img is not None: + if apply_spect_mapping: + spect_map_valid: Dict[str, str] = {} + spect_raw_keys: List[str] = [] + if final_spect_mapping is not None: + for k in nm_masks.keys(): + dst = final_spect_mapping.get(k) + if dst is not None and _is_valid_target(dst): + spect_map_valid[k] = dst + else: + spect_raw_keys.append(k) + else: + # auto map + for k in nm_masks.keys(): + dst = _canonical_mask_name(k) + if _is_valid_target(dst): + spect_map_valid[k] = dst + else: + spect_raw_keys.append(k) + + if spect_map_valid: + longSPECT.add_masks_to_time_point( + time_id=time_id, masks=nm_masks, mask_mapping=spect_map_valid + ) + if spect_raw_keys: + longSPECT.add_raw_masks_to_time_point( + time_id=time_id, + masks={k: nm_masks[k] for k in spect_raw_keys}, + ) + # Track used mapping (identity for raw keys) + spect_map = { + **{k: v for k, v in spect_map_valid.items()}, + **{k: k for k in spect_raw_keys}, + } + else: + longSPECT.add_raw_masks_to_time_point(time_id=time_id, masks=nm_masks) + spect_map = {k: k for k in nm_masks.keys()} + else: + spect_map = {} + + # Store with study origin labels + used_mappings[time_id] = {"ct": ct_map, "spect": spect_map} + + return longCT, longSPECT, inj, used_mappings diff --git a/pytheranostics/imaging_ds/dicom_ingest.py b/pytheranostics/imaging_ds/dicom_ingest.py new file mode 100644 index 0000000..48305e1 --- /dev/null +++ b/pytheranostics/imaging_ds/dicom_ingest.py @@ -0,0 +1,334 @@ +""" +DICOM ingestion utilities for PyTheranostics. + +Simplifies data ingestion for dosimetry workflows. +""" + +import logging +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import pydicom + +logger = logging.getLogger(__name__) + + +class DosimetryStudyOrganizer: + """ + Organize DICOM studies for dosimetry analysis. + + Automatically handles multiple time points, extracts metadata, and structures files. + """ + + def __init__(self, base_dir: Path): + """ + Initialize the study organizer. + + Parameters + ---------- + base_dir : Path + Base directory containing DICOM files + """ + self.base_dir = Path(base_dir) + self.patient_info = {} + self.time_points = [] + + def scan_directory(self) -> Dict: + """ + Scan directory structure and identify time points automatically. + + Returns + ------- + dict + Dictionary with organized study information + """ + study_info = { + "patient_id": None, + "time_points": [], + "ct_paths": [], + "spect_paths": [], + "rtstruct_files": [], + "injection_info": {}, + } + + # Look for time point directories (tp1, tp2, etc.) or organize by series time + tp_dirs = sorted(self.base_dir.glob("tp*")) + + if not tp_dirs: + # Try to auto-detect time points from DICOM metadata + logger.info("No tp* directories found, attempting auto-detection") + tp_dirs = self._auto_detect_time_points() + + for tp_dir in tp_dirs: + tp_info = self._process_time_point(tp_dir) + if tp_info: + study_info["time_points"].append(tp_info) + study_info["ct_paths"].append(tp_info.get("ct_path")) + study_info["spect_paths"].append(tp_info.get("spect_path")) + + if tp_info.get("rtstruct_file"): + study_info["rtstruct_files"].append(tp_info["rtstruct_file"]) + + # Extract patient info from first available DICOM + if study_info["time_points"]: + first_tp = study_info["time_points"][0] + study_info["patient_id"] = first_tp.get("patient_id") + study_info["injection_info"] = first_tp.get("injection_info", {}) + + return study_info + + def _process_time_point(self, tp_dir: Path) -> Optional[Dict]: + """ + Process a single time point directory. + + Parameters + ---------- + tp_dir : Path + Time point directory + + Returns + ------- + dict or None + Time point information + """ + tp_info = { + "name": tp_dir.name, + "path": tp_dir, + "ct_path": None, + "spect_path": None, + "rtstruct_file": None, + "patient_id": None, + "study_date": None, + "injection_info": {}, + } + + # Look for CT directory + ct_dir = tp_dir / "CT" + if ct_dir.exists(): + tp_info["ct_path"] = str(ct_dir) + + # Look for RT struct + rtstruct_dir = ct_dir / "RTstruct" + if rtstruct_dir.exists(): + rtstruct_files = list(rtstruct_dir.glob("*.dcm")) + if rtstruct_files: + tp_info["rtstruct_file"] = str(rtstruct_files[0]) + + # Extract patient info from CT + ct_files = list(ct_dir.glob("*.dcm")) + if ct_files: + try: + ds = pydicom.dcmread(ct_files[0], stop_before_pixels=True) + tp_info["patient_id"] = getattr(ds, "PatientID", None) + tp_info["study_date"] = getattr(ds, "StudyDate", None) + except Exception as e: + logger.warning(f"Could not read DICOM metadata: {e}") + + # Look for SPECT/NM directory + spect_dir = tp_dir / "SPECT" + if spect_dir.exists(): + tp_info["spect_path"] = str(spect_dir) + + # Extract injection information from SPECT + spect_files = list(spect_dir.glob("*.dcm")) + if spect_files: + try: + ds = pydicom.dcmread(spect_files[0], stop_before_pixels=True) + tp_info["injection_info"] = self._extract_injection_info(ds) + except Exception as e: + logger.warning(f"Could not extract injection info: {e}") + + return tp_info if (tp_info["ct_path"] or tp_info["spect_path"]) else None + + def _extract_injection_info(self, ds: pydicom.Dataset) -> Dict: + """ + Extract injection information from a DICOM dataset. + + Parameters + ---------- + ds : pydicom.Dataset + DICOM dataset (typically SPECT/NM) + + Returns + ------- + dict + Injection information + """ + info = { + "patient_weight_kg": getattr(ds, "PatientWeight", None), + "injection_date": None, + "injection_time": None, + "injected_activity": None, + "radiopharmaceutical": None, + } + + # Convert patient weight to grams + if info["patient_weight_kg"]: + info["patient_weight_g"] = int(info["patient_weight_kg"] * 1000) + + # Extract from RadiopharmaceuticalInformationSequence + if hasattr(ds, "RadiopharmaceuticalInformationSequence"): + rp_seq = ds.RadiopharmaceuticalInformationSequence + if len(rp_seq) > 0: + rp_info = rp_seq[0] + info["radiopharmaceutical"] = getattr( + rp_info, "Radiopharmaceutical", None + ) + info["injected_activity"] = getattr( + rp_info, "RadionuclideTotalDose", None + ) + + # Extract date and time + inj_date = getattr(rp_info, "RadiopharmaceuticalStartDate", None) + inj_time = getattr(rp_info, "RadiopharmaceuticalStartTime", None) + + if inj_date: + info["injection_date"] = inj_date + if inj_time: + # Format time to HHMMSS + info["injection_time"] = inj_time.split(".")[ + 0 + ] # Remove fractional seconds + + return info + + def _auto_detect_time_points(self) -> List[Path]: + """ + Auto-detect time points from a flat directory structure. + + Returns + ------- + list of Path + Detected time point directories + """ + # This would implement logic to group DICOM files by acquisition time + # and create virtual time points + logger.warning( + "Auto-detection not fully implemented. Please use tp1, tp2, etc. structure" + ) + return [] + + def cleanup_nested_folders(self, directory: Path): + """ + Clean up nested folder structures (removes single-child folders). + + Parameters + ---------- + directory : Path + Directory to clean up + """ + import shutil + + for subfolder in directory.iterdir(): + if subfolder.is_dir(): + inner_subfolders = list(subfolder.iterdir()) + if len(inner_subfolders) == 1 and inner_subfolders[0].is_dir(): + # Move contents up one level + for item in inner_subfolders[0].iterdir(): + if item.exists(): + shutil.move(str(item), str(directory)) + + # Remove empty nested folders + if inner_subfolders[0].exists(): + shutil.rmtree(str(inner_subfolders[0])) + if subfolder.exists(): + shutil.rmtree(str(subfolder)) + + +def auto_setup_dosimetry_study( + base_dir: Path, patient_id: Optional[str] = None, cleanup: bool = True +) -> Tuple[Dict, List[str], List[str], List[str]]: + """ + Automatically set up a dosimetry study from a directory of DICOM files. + + Parameters + ---------- + base_dir : Path + Base directory containing the study data + patient_id : str, optional + Patient ID (if None, will be extracted from DICOM) + cleanup : bool + Whether to clean up nested folder structures + + Returns + ------- + tuple + (study_info, ct_paths, spect_paths, rtstruct_files) + """ + organizer = DosimetryStudyOrganizer(base_dir) + + # Clean up if requested + if cleanup: + for tp_dir in base_dir.glob("tp*"): + if (tp_dir / "CT").exists(): + organizer.cleanup_nested_folders(tp_dir / "CT") + if (tp_dir / "CT" / "RTstruct").exists(): + organizer.cleanup_nested_folders(tp_dir / "CT" / "RTstruct") + if (tp_dir / "SPECT").exists(): + organizer.cleanup_nested_folders(tp_dir / "SPECT") + + # Scan and organize + study_info = organizer.scan_directory() + + # Override patient_id if provided + if patient_id: + study_info["patient_id"] = patient_id + + return ( + study_info, + study_info["ct_paths"], + study_info["spect_paths"], + study_info["rtstruct_files"], + ) + + +def extract_patient_metadata(dicom_dir: Path) -> Dict: + """ + Extract patient metadata from the first DICOM file in a directory. + + Parameters + ---------- + dicom_dir : Path + Directory containing DICOM files + + Returns + ------- + dict + Patient metadata + """ + dcm_files = list(Path(dicom_dir).glob("**/*.dcm")) + + if not dcm_files: + raise ValueError(f"No DICOM files found in {dicom_dir}") + + ds = pydicom.dcmread(dcm_files[0], stop_before_pixels=True) + + metadata = { + "patient_id": getattr(ds, "PatientID", "UNKNOWN"), + "patient_name": str(getattr(ds, "PatientName", "UNKNOWN")), + "patient_weight_kg": getattr(ds, "PatientWeight", None), + "study_date": getattr(ds, "StudyDate", None), + "study_time": getattr(ds, "StudyTime", None), + } + + # Try to extract injection info for NM modality + if getattr(ds, "Modality", "") in ["NM", "PT"]: + if hasattr(ds, "RadiopharmaceuticalInformationSequence"): + rp_seq = ds.RadiopharmaceuticalInformationSequence + if len(rp_seq) > 0: + rp_info = rp_seq[0] + metadata["injected_activity"] = getattr( + rp_info, "RadionuclideTotalDose", None + ) + metadata["injection_date"] = getattr( + rp_info, "RadiopharmaceuticalStartDate", None + ) + metadata["injection_time"] = getattr( + rp_info, "RadiopharmaceuticalStartTime", None + ) + if metadata["injection_time"]: + metadata["injection_time"] = metadata["injection_time"].split(".")[ + 0 + ] + + return metadata diff --git a/pytheranostics/imaging_ds/longitudinal_study.py b/pytheranostics/imaging_ds/longitudinal_study.py new file mode 100644 index 0000000..44edebc --- /dev/null +++ b/pytheranostics/imaging_ds/longitudinal_study.py @@ -0,0 +1,965 @@ +"""Module for longitudinal medical imaging studies.""" + +import json +import os +import re +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Union + +import numpy +import SimpleITK +from numpy.typing import NDArray + +from pytheranostics.imaging_ds.metadata import ImagingMetadata +from pytheranostics.imaging_tools.tools import ( + itk_image_from_array, + jaccard_index, + load_from_dicom_dir, + resample_mask_to_target, +) +from pytheranostics.registration.phantom_to_ct import PhantomToCTBoneReg + + +class LongitudinalStudy: + """Longitudinal Study Data Class. + + Holds multiple medical imaging datasets, alongside with masks for organs/regions + of interest and meta-data. + """ + + _VALID_ORGAN_NAMES = [ + "Kidney_Left", + "Kidney_Right", + "Liver", + "Spleen", + "Bladder", + "SubmandibularGland_Left", + "SubmandibularGland_Right", + "ParotidGland_Left", + "ParotidGland_Right", + "BoneMarrow", + "Skeleton", + "WholeBody", + "RemainderOfBody", + "TotalTumorBurden", + ] + + def __init__( + self, + images: Dict[int, SimpleITK.Image], + meta: Dict[int, ImagingMetadata], + modality: str = "NM", + ) -> None: + """Initialize a LongitudinalStudy instance. + + Args: + images (Dict[int, SimpleITK.Image]): Dictionary of (time-point ID, SimpleITK.Image) + representing CT or quantitative nuclear medicine images for each time point + in the longitudinal study. + meta (Dict[int, ImagingMetadata]): Dictionary of (time-point ID, ImagingMetadata) + representing metadata for each time point, containing acquisition details + and radionuclide information. + modality (str, optional): The imaging modality type. Supported values are "NM" + (Nuclear Medicine), "PT" (PET), "CT", or "DOSE". Defaults to "NM". + + Raises + ------ + ValueError + If the specified modality is not one of the supported values: + "NM", "PT", "CT", or "DOSE". + + Note + ---- + The constructor initializes an empty masks dictionary that can be populated later + using the `add_masks_to_time_point` method. It also defines a comprehensive list + of valid mask names for regions of interest including organs, glands, and lesions. + """ + if images.keys() != meta.keys(): + raise ValueError( + "Not all time points have corresponding images and metadata." + ) + + # TODO Consistency checks: verify that there are no missing masks across time points. + # NOTE: Such consistency would involve running add_mask_to_time_point() in __init__ + + if modality not in ["NM", "PT", "CT", "DOSE"]: + raise ValueError(f"Modality {modality} is not supported.") + + self.modality = modality + self.images = images + self.meta = meta + self.masks: Dict[int, Dict[str, NDArray[numpy.bool_]]] = ( + {} + ) # {time_id: {mask_name: array}} + + return None + + @classmethod + def from_dicom( + cls, + dicom_dirs: List[str], + modality: str = "CT", + calibration_factor: Optional[float] = None, + parallel: bool = True, + max_workers: Optional[int] = None, + ) -> "LongitudinalStudy": + """Create a LongitudinalStudy object from a list of DICOM directories. + + Currently assumes the order of the list corresponds to the order of the time points. + + Args: + dicom_dirs (List[str]): List of paths to DICOM directories, each containing + images for one time point in the longitudinal study. + modality (str, optional): The imaging modality. Supported values are "CT" + and "Lu177_SPECT". Defaults to "CT". + calibration_factor (float, optional): Converts reconstructed SPECT image + (raw counts * num_proj) to units of Bq/mL. Defaults to None. + parallel (bool, optional): Whether to load DICOM directories in parallel. + Defaults to True for faster loading of multiple timepoints. + max_workers (int, optional): Maximum number of parallel workers. If None, + defaults to min(number of CPUs, number of directories). + + Returns + ------- + LongitudinalStudy + A new LongitudinalStudy instance containing the loaded + DICOM data organized by time points. + + Raises + ------ + ValueError + If the specified modality is not supported. + """ + # TODO: should fix this to make it robust and look at dicom header info for sorting time-points. + supported_modalities = { + "CT": "CT", + "Lu177_SPECT": "NM", + } + if modality not in supported_modalities.keys(): + raise ValueError( + f"Modality '{modality}' not supported. Currently, the following modalities are supported: {list(supported_modalities.keys())}" + ) + internal_modality = supported_modalities[modality] + + images: Dict[int, SimpleITK.Image] = {} + metadata: Dict[int, ImagingMetadata] = {} + + if parallel and len(dicom_dirs) > 1: + # Parallel loading for multiple timepoints + print(f"Loading {len(dicom_dirs)} {modality} timepoints in parallel...") + + # Helper function for parallel execution + def load_single_timepoint(args): + time_id, dicom_dir = args + print(f" Loading timepoint {time_id} from {Path(dicom_dir).name}...") + return time_id, load_from_dicom_dir( + dir=dicom_dir, + modality=modality, + calibration_factor=calibration_factor, + ) + + # Use ThreadPoolExecutor for I/O-bound DICOM loading + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit( + load_single_timepoint, (time_id, dicom_dir) + ): time_id + for time_id, dicom_dir in enumerate(dicom_dirs) + } + + for future in as_completed(futures): + time_id, (image, meta) = future.result() + images[time_id] = image + metadata[time_id] = meta + print(f" ✓ Timepoint {time_id} loaded") + else: + # Sequential loading + for time_id, dicom_dir in enumerate(dicom_dirs): + print(f"Loading timepoint {time_id} from {Path(dicom_dir).name}...") + image, meta = load_from_dicom_dir( + dir=dicom_dir, + modality=modality, + calibration_factor=calibration_factor, + ) + images[time_id] = image + metadata[time_id] = meta + + return cls( + images=images, + meta=metadata, + modality=internal_modality, + ) + + @staticmethod + def _is_valid_mask_name(mask_name: str) -> bool: + """Check if a mask name is valid. + + Valid names are either: + - Standard organ names from _VALID_ORGAN_NAMES + - Lesion names in format 'Lesion_N' where N is a positive integer + """ + if mask_name in LongitudinalStudy._VALID_ORGAN_NAMES: + return True + lesion_pattern = r"^Lesion_([1-9]\d*)$" + return bool(re.match(lesion_pattern, mask_name)) + + # --- ROI name normalization & mapping helpers ----------------------------------------- + + @staticmethod + def canonical_roi_name(name: str) -> str: + """Return a best-effort canonical ROI name for pyTheranostics/Olinda. + + This performs lightweight normalization of common suffixes and synonyms, keeping + unknown names as-is so users can decide later. + + Rules applied: + - Drop training suffixes like "_m" (morphology/CT) and "_a" (activity/NM) + - Map frequent abbreviations to long-form organ names used across the codebase + """ + base = name + if base.endswith("_m") or base.endswith("_a"): + base = base[:-2] + + synonyms = { + "Kidney_L": "Kidney_Left", + "Kidney_R": "Kidney_Right", + "Parotid_L": "ParotidGland_Left", + "Parotid_R": "ParotidGland_Right", + "Submandibular_L": "SubmandibularGland_Left", + "Submandibular_R": "SubmandibularGland_Right", + "WBCT": "WholeBody", + "WB": "WholeBody", + } + return synonyms.get(base, base) + + @classmethod + def propose_mapping_from_names(cls, names: Iterable[str]) -> Dict[str, str]: + """Propose a mapping from raw ROI names to canonical targets. + + Parameters + ---------- + names : Iterable[str] + Collection of raw ROI names (e.g., as found in RTSTRUCT files). + + Returns + ------- + Dict[str, str] + Proposed mapping {raw_name: canonical_name} using lightweight rules. + """ + return {n: cls.canonical_roi_name(n) for n in set(names)} + + @classmethod + def propose_mapping_from_studies( + cls, studies: Iterable["LongitudinalStudy"] + ) -> Dict[str, str]: + """Propose a mapping from all mask names found across multiple studies. + + Parameters + ---------- + studies : Iterable[LongitudinalStudy] + One or more LongitudinalStudy instances (e.g., SPECT and CT). + + Returns + ------- + Dict[str, str] + Proposed mapping {raw_name: canonical_name} across all timepoints. + """ + raw: set[str] = set() + for study in studies: + for _, masks in study.masks.items(): + raw.update(masks.keys()) + return cls.propose_mapping_from_names(raw) + + def rename_masks( + self, mapping: Dict[str, str], *, validate_targets: bool = True + ) -> None: + """Rename masks in-place according to a mapping. + + Parameters + ---------- + mapping : Dict[str, str] + Dictionary mapping source names to destination names. + validate_targets : bool, optional + If True, only apply renames where the destination is a valid mask name. + """ + for time_id, masks in self.masks.items(): + for src, dst in mapping.items(): + if src in masks: + if validate_targets and not self._is_valid_mask_name(dst): + # Skip invalid targets to avoid breaking downstream + continue + masks[dst] = masks[src] + if dst != src: + try: + del masks[src] + except KeyError: + pass + return None + + def missing_targets(self, required: Iterable[str]) -> Dict[int, List[str]]: + """Report which required mask names are missing at each timepoint. + + Parameters + ---------- + required : Iterable[str] + Canonical ROI names expected to be present (e.g., from config). + + Returns + ------- + Dict[int, List[str]] + Per-timepoint list of missing ROI names (empty dict means all present). + """ + req_set = set(required) + missing: Dict[int, List[str]] = {} + for tp in sorted(self.images.keys()): + have = set(self.masks.get(tp, {}).keys()) + miss = sorted(list(req_set - have)) + if miss: + missing[tp] = miss + return missing + + @staticmethod + def apply_per_modality_mappings( + ct_study: "LongitudinalStudy", + spect_study: "LongitudinalStudy", + ct_mask_mapping: Optional[Dict[str, str]] = None, + spect_mask_mapping: Optional[Dict[str, str]] = None, + manual_overrides: Optional[Dict[str, str]] = None, + validate_targets: bool = True, + ) -> Dict[str, Any]: + """Apply modality-specific mask mappings to CT and SPECT studies. + + This helper automates the workflow: + 1. Merge user-provided mappings with manual overrides. + 2. Filter each mapping to keys actually present in each study. + 3. Check for conflicts (multiple sources mapping to the same target within a study). + 4. Apply renames in-place to both studies. + 5. Return diagnostic info (applied mappings, absent keys, conflicts). + + Parameters + ---------- + ct_study : LongitudinalStudy + The CT longitudinal study (used for volume/morphology). + spect_study : LongitudinalStudy + The SPECT/NM longitudinal study (used for activity). + ct_mask_mapping : Optional[Dict[str, str]], optional + User-provided mapping {raw_name: canonical_name} for CT masks. + If None, proposes a mapping automatically from CT mask names. + spect_mask_mapping : Optional[Dict[str, str]], optional + User-provided mapping {raw_name: canonical_name} for SPECT masks. + If None, proposes a mapping automatically from SPECT mask names. + manual_overrides : Optional[Dict[str, str]], optional + Additional mappings to override both CT and SPECT proposals (e.g., lesion mappings). + validate_targets : bool, optional + If True, only apply renames where the destination is a valid mask name (default: True). + + Returns + ------- + Dict[str, Any] + Diagnostic dictionary with keys: + - 'ct_applied': Dict[str, str] - mappings applied to CT + - 'spect_applied': Dict[str, str] - mappings applied to SPECT + - 'ct_absent': List[str] - CT mapping keys not found in CT masks + - 'spect_absent': List[str] - SPECT mapping keys not found in SPECT masks + - 'ct_conflicts': Dict[str, List[str]] - CT conflicts (target: [sources]) + - 'spect_conflicts': Dict[str, List[str]] - SPECT conflicts (target: [sources]) + + Example + ------- + >>> ct_mapping = { + ... "Kidney_L_m": "Kidney_Left", + ... "Kidney_R_m": "Kidney_Right", + ... "Liver": "Liver", + ... } + >>> spect_mapping = { + ... "Kidney_L_a": "Kidney_Left", + ... "Kidney_R_a": "Kidney_Right", + ... "Liver": "Liver", + ... } + >>> result = LongitudinalStudy.apply_per_modality_mappings( + ... ct_study=longCT, + ... spect_study=longSPECT, + ... ct_mask_mapping=ct_mapping, + ... spect_mask_mapping=spect_mapping, + ... ) + >>> print(result['ct_applied']) + """ + manual = manual_overrides or {} + + # Default to auto-proposal if no explicit mapping provided + if ct_mask_mapping is None: + ct_names = set() + for masks in ct_study.masks.values(): + ct_names.update(masks.keys()) + ct_mask_mapping = LongitudinalStudy.propose_mapping_from_names(ct_names) + + if spect_mask_mapping is None: + spect_names = set() + for masks in spect_study.masks.values(): + spect_names.update(masks.keys()) + spect_mask_mapping = LongitudinalStudy.propose_mapping_from_names( + spect_names + ) + + # Merge manual overrides (take precedence) + mapping_ct = dict(ct_mask_mapping) + mapping_ct.update(manual) + + mapping_spect = dict(spect_mask_mapping) + mapping_spect.update(manual) + + # Gather which source keys actually exist in each study + def _keys_in_study(study: "LongitudinalStudy") -> set: + present = set() + for masks in study.masks.values(): + present.update(masks.keys()) + return present + + ct_present_keys = _keys_in_study(ct_study) + spect_present_keys = _keys_in_study(spect_study) + + # Filter mappings to present keys + filtered_ct = {k: v for k, v in mapping_ct.items() if k in ct_present_keys} + filtered_spect = { + k: v for k, v in mapping_spect.items() if k in spect_present_keys + } + + # Track absent keys (provided but not present) + absent_ct = sorted([k for k in mapping_ct.keys() if k not in ct_present_keys]) + absent_spect = sorted( + [k for k in mapping_spect.keys() if k not in spect_present_keys] + ) + + # Check for conflicts (multiple sources -> same target within a study) + def _check_conflicts(mapping: Dict[str, str]) -> Dict[str, List[str]]: + inv: Dict[str, List[str]] = {} + for src, dst in mapping.items(): + inv.setdefault(dst, []).append(src) + return {dst: srcs for dst, srcs in inv.items() if len(srcs) > 1} + + conflicts_ct = _check_conflicts(filtered_ct) + conflicts_spect = _check_conflicts(filtered_spect) + + # Apply renames in-place + ct_study.rename_masks(filtered_ct, validate_targets=validate_targets) + spect_study.rename_masks(filtered_spect, validate_targets=validate_targets) + + return { + "ct_applied": filtered_ct, + "spect_applied": filtered_spect, + "ct_absent": absent_ct, + "spect_absent": absent_spect, + "ct_conflicts": conflicts_ct, + "spect_conflicts": conflicts_spect, + } + + @staticmethod + def load_mappings_from_json( + json_path: Union[str, Path], + ) -> Dict[str, Dict[str, str]]: + """Load CT and SPECT mask mappings from a JSON configuration file. + + The JSON file should contain a dictionary with keys 'ct_mappings' and/or + 'spect_mappings', each mapping to a dictionary of {raw_name: canonical_name}. + + Parameters + ---------- + json_path : Union[str, Path] + Path to the JSON configuration file. + + Returns + ------- + Dict[str, Dict[str, str]] + Dictionary with keys: + - 'ct_mappings': Dict[str, str] - CT mask name mappings + - 'spect_mappings': Dict[str, str] - SPECT mask name mappings + + Raises + ------ + FileNotFoundError + If the JSON file does not exist. + json.JSONDecodeError + If the JSON file is malformed. + KeyError + If the JSON file does not contain expected keys. + + Example + ------- + >>> mappings = LongitudinalStudy.load_mappings_from_json("roi_mappings.json") + >>> result = LongitudinalStudy.apply_per_modality_mappings( + ... ct_study=longCT, + ... spect_study=longSPECT, + ... ct_mask_mapping=mappings['ct_mappings'], + ... spect_mask_mapping=mappings['spect_mappings'], + ... ) + + Example JSON format + ------------------- + { + "ct_mappings": { + "Kidney_L_m": "Kidney_Left", + "Kidney_R_m": "Kidney_Right", + "Liver": "Liver" + }, + "spect_mappings": { + "Kidney_L_a": "Kidney_Left", + "Kidney_R_a": "Kidney_Right", + "Liver": "Liver" + } + } + """ + path = Path(json_path) + if not path.exists(): + raise FileNotFoundError(f"Mapping config file not found: {json_path}") + + with open(path, "r") as f: + config = json.load(f) + + # Validate structure + if not isinstance(config, dict): + raise ValueError( + f"Expected JSON to contain a dictionary, got {type(config).__name__}" + ) + + result = { + "ct_mappings": config.get("ct_mappings", {}), + "spect_mappings": config.get("spect_mappings", {}), + } + + # Validate that each mapping is a dict + for key in ["ct_mappings", "spect_mappings"]: + if not isinstance(result[key], dict): + raise ValueError( + f"Expected '{key}' to be a dictionary, got {type(result[key]).__name__}" + ) + + return result + + def array_at(self, time_id: int) -> NDArray[Any]: + """Access Array Data. + + Parameters + ---------- + time_id : int + The time point ID. + + Returns + ------- + NDArray[Any] + The array data at the specified time point. + """ + return numpy.transpose( + numpy.squeeze(SimpleITK.GetArrayFromImage(self.images[time_id])), + axes=(1, 2, 0), + ) + + def array_of_activity_at( + self, time_id: int, region: Optional[str] = None + ) -> NDArray[Any]: + """Return the array in units of activity in Bq. + + With the posibility of masking out for one specific region. + """ + if self.modality not in ["NM", "PT"]: + raise ValueError(f"Activity can't be calculated from {self.modality} data.") + + if time_id not in self.images: + raise ValueError(f"Time ID {time_id} not found in dataset.") + + array = self.array_at(time_id=time_id) + + if region is None: + mask = numpy.ones(shape=array.shape, dtype=numpy.bool_) + else: + if time_id not in self.masks: + raise ValueError( + f"Time ID {time_id} does not include mask data. Did you run " + "add_masks_to_time_point()?" + ) + if region not in self.masks[time_id]: + available_regions = list(self.masks[time_id].keys()) + raise ValueError( + f"Region {region} not found in masks for time ID {time_id}. " + f"Available regions: {available_regions}" + ) + if self.masks[time_id][region].shape != array.shape: + raise ValueError( + f"Mask shape {self.masks[time_id][region].shape} doesn't match " + f"array shape {array.shape} for time ID {time_id}" + ) + mask = self.masks[time_id][region] + + return array * mask * self.voxel_volume(time_id=time_id) + + def add_masks_to_time_point( + self, + time_id: int, + masks: Dict[str, SimpleITK.Image], + mask_mapping: Optional[Dict[str, str]] = None, + ) -> None: + """Add Masks to time point. + + Args: + time_id (int): Index of time-point ID. + masks (Dict[str, SimpleITK.Image]): Dictionary containing masks for time point time_id, in the format {mask_name: mask_image (simpleITK)} + mask_mapping (Optional[Dict[str, str]], optional): Mapping between masks names in input masks dictionary, and standard mask names in pyTheranostics. Defaults to None. If None, takes each name as is. + + Raises + ------ + ValueError + If mapping between user input masks and pyTheranostics standard mask names is invalid. + + """ + # If mask mapping is not specified, utilize user defined names in masks Dictionary. + if mask_mapping is None: + mask_mapping = {mask_name: mask_name for mask_name in masks.keys()} + + if time_id not in self.masks: + self.masks[time_id] = {} + + for mask_source, mask_target in mask_mapping.items(): + + if mask_source not in masks: + raise ValueError( + f"{mask_source} is not part of the available masks: {masks.keys()}" + ) + + if not self._is_valid_mask_name(mask_target): + raise ValueError( + f"{mask_target} is not a valid mask name. Please use one of: " + f"\n{self._VALID_ORGAN_NAMES}\nor 'Lesion_N' where N is a positive integer." + ) + + if mask_target in self.masks[time_id]: + print( + f"Warning: {mask_target} found at Time = {time_id}. It will be over-written!" + ) + + # Masks are in the right orientation and spacing, however there could be discrepancies + # in array shapes (reason, unknown). We resample to ensure shapes between image and + # masks are consistent. + # TODO: Fix. + mask_ = resample_mask_to_target( + mask_img=masks[mask_source], target_img=self.images[time_id] + ) + + mask_array = numpy.transpose( + SimpleITK.GetArrayFromImage(mask_), axes=(1, 2, 0) + ) + self.masks[time_id][mask_target] = mask_array.astype(numpy.bool_) + + return None + + def add_raw_masks_to_time_point( + self, + time_id: int, + masks: Dict[str, SimpleITK.Image], + *, + resample_to_image_geometry: bool = True, + ) -> None: + """Add masks using their incoming names without validating or remapping. + + This is a permissive import method intended for early data ingestion. + It stores masks under their original ROI names as found in RTSTRUCT or + other sources. Downstream workflows can later inspect available names + and explicitly normalize or remap to canonical labels. + + Args + ---- + time_id : int + Index of the time point to which masks will be added. + masks : Dict[str, SimpleITK.Image] + Dictionary of incoming masks {roi_name: sitk.Image}. + resample_to_image_geometry : bool + If True, resample each mask to match the study image geometry at + this time point to ensure consistent array shapes. Defaults to True. + + Notes + ----- + - No validation is performed on the ROI names. + - Existing masks with the same name at this time_id will be overwritten. + """ + if time_id not in self.masks: + self.masks[time_id] = {} + + for mask_name, mask_img in masks.items(): + # Optionally enforce geometry consistency + mask_itk = ( + resample_mask_to_target( + mask_img=mask_img, target_img=self.images[time_id] + ) + if resample_to_image_geometry + else mask_img + ) + + mask_array = numpy.transpose( + SimpleITK.GetArrayFromImage(mask_itk), axes=(1, 2, 0) + ) + if mask_name in self.masks[time_id]: + print( + f"Warning: {mask_name} found at Time = {time_id}. It will be over-written!" + ) + self.masks[time_id][mask_name] = mask_array.astype(numpy.bool_) + + return None + + def volume_of(self, region: str, time_id: int) -> float: + """Return the volume of a region of interest, in mL. + + Parameters + ---------- + region : str + The region name. + time_id : int + The time point ID. + + Returns + ------- + float + Volume in mL. + """ + return numpy.sum(self.masks[time_id][region]) * self.voxel_volume( + time_id=time_id + ) + + def activity_in(self, region: str, time_id: int) -> float: + """Return the activity within a region of interest. + + The units of the nuclear medicine data should be Bq/mL. + """ + if self.meta[time_id].Radionuclide is None or self.modality not in ["NM", "PT"]: + raise AssertionError( + "Can't compute activity if the image data does not represent the distribution of a radionuclide" + ) + return numpy.sum( + self.masks[time_id][region] + * self.array_at(time_id=time_id) + * self.voxel_volume(time_id=time_id) + ) + + def density_of(self, region: str, time_id: int) -> float: + """Return the mean density of region of interest, in HU. + + Parameters + ---------- + region : str + The region name. + time_id : int + The time point ID. + + Returns + ------- + float + Mean density in HU. + """ + return float( + numpy.mean(self.array_at(time_id=time_id)[self.masks[time_id][region] > 0]) + ) + + def voxel_volume(self, time_id: int) -> float: + """Return the volume of a voxel in mL. + + Parameters + ---------- + time_id : int + The time point ID. + + Returns + ------- + float + Voxel volume in mL. + """ + spacing = self.images[time_id].GetSpacing() + return float(spacing[0] / 10 * spacing[1] / 10 * spacing[2] / 10) + + def average_of(self, region: str, time_id: int) -> float: + """Compute average value in a region. + + Args: + region (str): The region name. + time_id (int): The time point ID. + + Returns + ------- + float + Average value in the region. + """ + return float( + numpy.average(self.array_at(time_id=time_id)[self.masks[time_id][region]]) + ) + + def add_bone_marrow_mask_from_phantom( + self, + phantom_skeleton_path: Path, + phantom_bone_marrow_path: Path, + num_iterations: int = 3, + ) -> None: + """Generate Bone Marrow mask on each time point. + + Registers a generic skeleton derived from an XCAT phantom into the patient's Skeleton + CT and subsequently applying this spatial transformation to register the phantom's bone + marrow into the patient's anatomy. + + Args + ---- + phantom_skeleton_path (Path): Path to phantom Skeleton .nii file. + phantom_bone_marrow_path (Path): Path to phantom Bone Marrow .nii file. + """ + print( + "Running Personalized Bone Marrow generation from XCAT Phantom. This feature is unstable. Please review the generated BoneMarrow masks." + ) + + if self.modality != "CT": + raise AssertionError( + f"Phantom skeleton can only be registered to CT data. This is modality = {self.modality}" + ) + + if "Skeleton" not in self.masks[0]: + raise AssertionError("Skeleton mask not found. Can't continue.") + + # Since algorithm is not very stable (sometimes registration fails), we perform multiple iterations (aka repetitions) and keep best + # results according to jaccard index. + best_index = {time_id: 0 for time_id in self.images.keys()} + + for i in range(num_iterations): + print(f"Registration :: Iteration {i+1}") + # Loop through each time point: + for time_id, ct in self.images.items(): + # Register Skeleton + print( + f" >> Registering Phantom Skeleton to CT at time point {time_id} ..." + ) + RegManager = PhantomToCTBoneReg( + CT=ct, phantom_skeleton_path=phantom_skeleton_path + ) + _ = RegManager.register( + fixed_image=RegManager.CT, moving_image=RegManager.Phantom + ) + + # Register Bone Marrow + marrow_mask = numpy.transpose( + SimpleITK.GetArrayFromImage( + RegManager.register_mask( + fixed_image=RegManager.CT, + mask_path=phantom_bone_marrow_path, + ) + ), + axes=(1, 2, 0), + ) + + # Threshold: + marrow_mask = marrow_mask >= 1 + + # Exclude voxels outside of the patient's skeleton. + marrow_mask *= self.masks[time_id]["Skeleton"] + + # Compute Index: + jaccard = jaccard_index(self.masks[time_id]["Skeleton"], marrow_mask) + + if jaccard > best_index[time_id]: + self.masks[time_id]["BoneMarrow"] = marrow_mask # Threshold. + best_index[time_id] = jaccard + + # Calculate Index + print( + f" >>> Jaccard Index between Skeleton and Segmented Bone Marrow: {jaccard: 1.2f}" + ) + + # Final Results: + print(" >>> Final Jaccard Indices:") + for time_id in self.masks.keys(): + print(f" >>> Time point {time_id}: {best_index[time_id]}") + + return None + + def check_masks_consistency(self) -> None: + """Check that we have the same masks in all time points. + + Raises + ------ + AssertionError + If masks are inconsistent across time points. + """ + masks_list = [sorted(list(masks.keys())) for _, masks in self.masks.items()] + + sample = masks_list[0] + + for masks in masks_list: + if masks != sample: + raise AssertionError(f"Incosistent Masks! -> {masks_list}") + + return None + + def save_image_to_nii_at( + self, time_id: int, out_path: Path, name: str = "" + ) -> None: + """Save Image from a particular time-point as a nifty file. + + Args + ---- + time_id (int): The time ID representing the time point to be saved. + out_path (Path): The path to the folder where images will be written. + """ + print(f"Writing Image ({name}) into nifty file.") + SimpleITK.WriteImage( + image=SimpleITK.Cast(self.images[time_id], SimpleITK.sitkInt32), + fileName=out_path / f"Image_{time_id}{name}.nii.gz", + ) + return None + + def save_image_to_mhd_at( + self, time_id: int, out_path: Path, name: str = "" + ) -> None: + """Save Image from a particular time-point as a nifty file. + + Args + ---- + time_id (int): The time ID representing the time point to be saved. + out_path (Path): The path to the folder where images will be written. + """ + print(f"Writing Image ({name}) into mhd file.") + SimpleITK.WriteImage( + image=SimpleITK.Cast(self.images[time_id], SimpleITK.sitkInt32), + fileName=os.path.join(out_path, f"{name}.mhd"), + ) + return None + + def save_masks_to_nii_at( + self, time_id: int, out_path: Path, regions: List[str] + ) -> None: + """Save Masks from a particular time-point as a nifty file. + + Args + ---- + time_id (int): The time ID representing the time point to be saved. + out_path (Path): The path to the folder where images will be written. + regions (List[str]): A list of regions (masks) to be saved. If empty, save all masks. + """ + mask_names = list(self.masks[time_id].keys()) + all_masks = numpy.zeros_like(self.masks[time_id][mask_names[0]]).astype( + numpy.int16 + ) # Get the shape of the first mask available. + + if len(regions) > 0: + mask_names = [ + region for region in regions if region in self.masks[time_id].keys() + ] + + for mask_id, region_name in enumerate(mask_names): + all_masks += (mask_id + 1) * (self.masks[time_id][region_name]).astype( + numpy.int16 + ) + + mask_image = itk_image_from_array( + array=numpy.transpose(all_masks, axes=(2, 0, 1)), + ref_image=self.images[time_id], + ) + + print(f"Writing Masks ({mask_names}) into nifty file.") + + SimpleITK.WriteImage( + image=mask_image, fileName=out_path / f"Masks_{time_id}.nii.gz" + ) + + return None diff --git a/pytheranostics/imaging_ds/mapping_summary.py b/pytheranostics/imaging_ds/mapping_summary.py new file mode 100644 index 0000000..c990026 --- /dev/null +++ b/pytheranostics/imaging_ds/mapping_summary.py @@ -0,0 +1,122 @@ +"""Utilities to summarize applied ROI mappings in longitudinal workflows. + +Provides a compact console summary and an optional JSON artifact +without bloating notebook output. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Dict, Iterable, List, Tuple + + +def _split_modalities( + mapping: Dict[str, str], +) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]: + """Split mapping into non-identity and identity pairs. + + Returns (non_identity_pairs, identity_pairs). + """ + non_identity = [(k, v) for k, v in mapping.items() if k != v] + identity = [(k, v) for k, v in mapping.items() if k == v] + return non_identity, identity + + +def summarize_used_mappings( + used_mappings: Dict[int, Dict[str, Dict[str, str]]], + *, + verbose: bool = False, + sample_limit: int = 20, + save_json_path: str | Path | None = "mapping_applied_summary.json", + include_unmapped: bool = True, +) -> None: + """Print a compact mapping summary and optionally save full details to JSON. + + Parameters + ---------- + used_mappings : Dict[int, Dict[str, Dict[str, str]]] + The mapping dictionary returned by create_studies_with_masks() + where each timepoint has {"ct": {raw->canonical}, "spect": {raw->canonical}}. + verbose : bool, optional + If True, print up to `sample_limit` non-identity pairs per modality. + sample_limit : int, optional + How many pairs to print per modality when verbose=True. + save_json_path : str | Path | None, optional + When provided, save the full per-timepoint mapping details to this JSON file. + Set to None to skip saving. + include_unmapped : bool, optional + If True, also report identity (unmapped) pairs separately for CT and SPECT. + """ + per_tp = {} + for tp, studies in sorted(used_mappings.items()): + ct_mapping = studies.get("ct", {}) + spect_mapping = studies.get("spect", {}) + + ct_mapped, ct_unmapped = _split_modalities(ct_mapping) + spect_mapped, spect_unmapped = _split_modalities(spect_mapping) + + entry = { + "ct": ct_mapped, + "spect": spect_mapped, + } + + if include_unmapped: + entry["unmapped_ct"] = sorted([k for k, v in ct_unmapped]) + entry["unmapped_spect"] = sorted([k for k, v in spect_unmapped]) + + per_tp[tp] = entry + + # Compact counts + for tp, parts in per_tp.items(): + ct_n = len(parts["ct"]) + sp_n = len(parts["spect"]) + msg = f"tp{tp}: CT {ct_n} | SPECT {sp_n} non-identity mappings" + if include_unmapped: + unmapped_ct_n = len(parts.get("unmapped_ct", [])) + unmapped_sp_n = len(parts.get("unmapped_spect", [])) + msg += f" | Unmapped: CT {unmapped_ct_n}, SPECT {unmapped_sp_n}" + print(msg) + + if verbose: + + def _print_pairs(label: str, pairs: Iterable[Tuple[str, str]]) -> None: + shown = 0 + for k, v in pairs: + print(f" {label}: {k} -> {v}") + shown += 1 + if shown >= sample_limit: + break + + _print_pairs("CT", parts["ct"]) + _print_pairs("SPECT", parts["spect"]) + + # Optionally list a small sample of unmapped names per modality + if include_unmapped: + if parts.get("unmapped_ct"): + sample_ct = parts["unmapped_ct"][:sample_limit] + print(f" Unmapped CT (identity): {sample_ct}") + if parts.get("unmapped_spect"): + sample_sp = parts["unmapped_spect"][:sample_limit] + print(f" Unmapped SPECT (identity): {sample_sp}") + + if save_json_path is not None: + out = { + int(tp): { + "ct": [{"from": k, "to": v} for k, v in parts["ct"]], + "spect": [{"from": k, "to": v} for k, v in parts["spect"]], + **( + { + "unmapped_ct": parts.get("unmapped_ct", []), + "unmapped_spect": parts.get("unmapped_spect", []), + } + if include_unmapped + else {} + ), + } + for tp, parts in per_tp.items() + } + save_path = Path(save_json_path) + with save_path.open("w") as f: + json.dump(out, f, indent=2) + print(f"Saved detailed mapping summary to {save_path}") diff --git a/pytheranostics/imaging_ds/metadata.py b/pytheranostics/imaging_ds/metadata.py new file mode 100644 index 0000000..414c1d6 --- /dev/null +++ b/pytheranostics/imaging_ds/metadata.py @@ -0,0 +1,9 @@ +"""Metadata structures for imaging datasets. + +This module re-exports shared metadata types to keep imaging_ds free of +cross-package dependencies and avoid circular imports. +""" + +from pytheranostics.shared.types import ImagingMetadata + +__all__ = ["ImagingMetadata"] diff --git a/pytheranostics/imaging_tools/__init__.py b/pytheranostics/imaging_tools/__init__.py new file mode 100644 index 0000000..860e77b --- /dev/null +++ b/pytheranostics/imaging_tools/__init__.py @@ -0,0 +1,6 @@ +"""Imaging tools and utilities for medical image processing. + +PEP 8 compliant package with lowercase module names. +""" + +__all__ = ["tools"] diff --git a/pytheranostics/imaging_tools/tools.py b/pytheranostics/imaging_tools/tools.py new file mode 100644 index 0000000..0cb3538 --- /dev/null +++ b/pytheranostics/imaging_tools/tools.py @@ -0,0 +1,811 @@ +"""Tools for medical image manipulation and processing.""" + +from __future__ import annotations + +import glob +from pathlib import Path +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple + +import numpy +import pydicom +import SimpleITK +from rt_utils import RTStructBuilder +from SimpleITK import Image + +from pytheranostics.dicomtools.dicomtools import sitk_load_dcm_series + +if TYPE_CHECKING: + # Imported only for type checking to avoid circular imports at runtime + from pytheranostics.imaging_ds.metadata import ImagingMetadata + +from pytheranostics.registration.ct_to_spect import ( + register_ct_to_spect, + transform_ct_mask_to_spect, +) + +# TODO: Move under dicomtools, and have two sets: one generic (the current dicomtools.py) and on specific for pyTheranostic functions (containing +# the code below) + + +def load_metadata(dir: str, modality: str) -> ImagingMetadata: + """Load relevant meta-data from a dicom dataset. + + Args: + dir (str): Directory path containing DICOM files. + modality (str): The imaging modality. + + Raises + ------ + AssertionError + If no DICOM data found in directory. + ValueError + If modality mismatch detected. + + Returns + ------- + ImagingMetadata + Metadata object with imaging information. + """ + # Convert Path to string if needed + dir_str = str(dir) + + dicom_slices = [ + pydicom.dcmread(fname) + for fname in glob.glob(dir_str + "/*.dcm", recursive=False) + ] + + if len(dicom_slices) == 0: + raise AssertionError(f"No Dicom data was found under {dir}") + + radionuclide = None + injected_activity = None + + if modality == "CT": + dicom_slices = [f for f in dicom_slices if hasattr(f, "SliceLocation")] + dicom_slices = sorted(dicom_slices, key=lambda s: s.SliceLocation) + + if dicom_slices[0].Modality != "CT": + raise ValueError( + f"Wrong modality. User specified CT, howere dicom indicates {dicom_slices[0].Modality}." + ) + + else: + if dicom_slices[0].Modality not in ["NM", "PT"]: + raise ValueError( + f"Wrong modality. User specified NM/PT, however dicom indicates {dicom_slices[0].Modality}." + ) + + radionuclide = modality.split("_")[0] + + # This only applies to Q-SPECT TODO: replace for something more generic. + injected_activity = None + + if hasattr(dicom_slices[0], "RadiopharmaceuticalInformationSequence"): + rp_seq = dicom_slices[0].RadiopharmaceuticalInformationSequence + if len(rp_seq) > 0: + try: + injected_activity = rp_seq[0].RadionuclideTotalDose + + # Currently we don't have a way to know the units ... so we use common sense. + if ( + injected_activity > 20000 + ): # Activity likely in Bq instead of MBq + injected_activity /= 1e6 + print( + f"Injected activity found in DICOM Header: {injected_activity:2.1f} MBq. Please verify." + ) + except AttributeError: + # Sequence exists but RadionuclideTotalDose attribute is missing + print( + "RadiopharmaceuticalInformationSequence found but RadionuclideTotalDose is missing." + ) + else: + # Sequence exists but is empty - this may indicate a data quality issue + print( + "Warning: RadiopharmaceuticalInformationSequence is empty. This may indicate a data quality issue." + ) + + if injected_activity is None: + print("Using default injected activity: 7400 MBq") + injected_activity = 7400.0 + + # Global attributes. Should be the same in all slices! + slice_ = dicom_slices[0] + + # Local import from shared types to avoid circular dependencies + from pytheranostics.shared.types import ImagingMetadata + + meta = ImagingMetadata( + PatientID=slice_.PatientID, + AcquisitionDate=slice_.AcquisitionDate, + AcquisitionTime=slice_.AcquisitionTime, + HoursAfterInjection=None, + Radionuclide=radionuclide, + Injected_Activity_MBq=injected_activity, + ) + + return meta + + +def itk_image_from_array( + array: numpy.ndarray, ref_image: Image, is_mask: bool = False +) -> Image: + """Create an ITK Image object with a new array and existing meta-data. + + Uses meta-data from another reference ITK image. + + Args: + array (numpy.ndarray): Array data for the new image. + ref_image (Image): Reference ITK image for metadata. + is_mask (bool): Whether the array represents a mask. + + Returns + ------- + Image + New ITK Image object with array data and reference metadata. + """ + # Cast if masks: + if is_mask: + array = array.astype(numpy.uint8) + + image = SimpleITK.GetImageFromArray(array) + + if is_mask: + image = SimpleITK.Cast(image, SimpleITK.sitkUInt8) + + # Set Manually basic meta: + tmp_spacing = list(ref_image.GetSpacing()) + tmp_origin = list(ref_image.GetOrigin()) + + if ( + len(tmp_spacing) - len(array.shape) == 1 + ): # Sometime we get NM data with 4 dimensions, the last one being just a dummy. + tmp_spacing = tmp_spacing[:-1] + tmp_origin = tmp_origin[:-1] + + image.SetSpacing([tmp_spacing[0], tmp_spacing[1], tmp_spacing[2]]) + image.SetOrigin(tmp_origin) + tmp_direction = list(ref_image.GetDirection()) + + if len(tmp_direction) > 9: + image.SetDirection( + tmp_direction[0:3] + tmp_direction[4:7] + tmp_direction[8:11] + ) + else: + image.SetDirection(tmp_direction) + + # Here we set the additional meta-data. + for key in ref_image.GetMetaDataKeys(): + image.SetMetaData(key, ref_image.GetMetaData(key)) + + return image + + +def apply_qspect_dcm_scaling( + image: Image, dir: str, scale_factor: Optional[Tuple[float, float]] = None +) -> Image: + """Read dicom metadata to extract appropriate scaling for Image voxel values. + + Apply scaling to original image and generate a new SimpleITK image object. + + Args: + image (Image): Original SimpleITK image. + dir (str): Directory path containing DICOM files. + scale_factor (Optional[Tuple[float, float]], optional): Custom scale factor. Defaults to None. + + Raises + ------ + AssertionError + If wrong modality or multiple DICOM files found. + + Returns + ------- + Image + Scaled SimpleITK image. + """ + if scale_factor is None: + # We use pydicom to access the appropriate tag: + # First, find the SPECT dicom file: + path_dir = Path(dir) + nm_files = [files for files in path_dir.glob("*.dcm")] + dcm_data = pydicom.dcmread(str(nm_files[0])) + + if ( + dcm_data.Modality == "PT" + ): # PET modality stores individual dicom files for each slice, similar to CT. + # Its scaling is readed correctly from SimpleITK, so nothing to do here. + return image + + if dcm_data.Modality != "NM": + raise AssertionError( + f"Wrong Modality, expecting NM for SPECT data, but got {dcm_data.Modality}" + ) + + if len(nm_files) != 1: + raise AssertionError( + f"Found more than 1 .dcm file inside {path_dir.name}, not sure which one is the right SPECT." + ) + + # If scale_factor is not provided by the user, we assume it is a Q-SPECT file. + slope = dcm_data.RealWorldValueMappingSequence[0].RealWorldValueSlope + intercept = dcm_data.RealWorldValueMappingSequence[0].RealWorldValueIntercept + else: + slope = scale_factor[0] + intercept = scale_factor[1] + + # Re-scale voxel values + image_array = numpy.squeeze(SimpleITK.GetArrayFromImage(image)) + image_array = slope * image_array.astype(numpy.float32) + intercept + + # Generate re-scaled SimpleITK.Image object by building a new Image from re_scaled array, and copying + # metadata from original image. + return itk_image_from_array(array=image_array, ref_image=image) + + +def apply_qspect_dcm_origin(image: Image, dir: str) -> Image: + """Apply Origin and Direction from dicom header if needed. + + This could happen when SPECT data is stored as a single .dcm file (i.e., stored as "NM" modality), + ITKSnap sometimes fails to read the Position and Direction correctly, so we pull it from pydicom. + + Parameters + ---------- + image : SimpleITK.Image + SimpleITK image object + dir : str + Path to dcm file containing SPECT reconstruction + + Returns + ------- + Image + Image with correct Origin and Direction + """ + # We use pydicom to access the appropriate tag: + # First, find the SPECT dicom file: + path_dir = Path(dir) + nm_files = [files for files in path_dir.glob("*.dcm")] + + dcm_data = pydicom.dcmread(str(nm_files[0])) + modality = dcm_data.Modality + + if ( + modality == "PT" + ): # PET modality stores individual dicom files for each slice, as CT. + # Therefore nothing to do here. + return image + elif modality != "NM": + raise AssertionError(f"Data is not SPECT. Modality found: {modality}") + + if len(nm_files) > 1: + raise AssertionError( + "Found more than 1 dicom file inside the folder but loaded sample is stored as NM." + " There should only be a single dicome file." + ) + + if getattr(dcm_data, "ImagePositionPatient", None) is None: + + # Verify SimpleITK got the right origin and direction + dcm_origin = dcm_data.DetectorInformationSequence[0].ImagePositionPatient + itk_origin = image.GetOrigin() + + for idx in range(len(dcm_origin)): + if abs(dcm_origin[idx] - itk_origin[idx]) > 0.1: + raise AssertionError( + f"Missmatch between DCM origin {dcm_origin} and ITK origin {itk_origin}" + ) + + dcm_direction = dcm_data.DetectorInformationSequence[0].ImageOrientationPatient + itk_direction = image.GetDirection() + + for idx in range(len(dcm_direction)): + if abs(dcm_direction[idx] - itk_direction[idx]) > 0.1: + raise AssertionError( + f"Missmatch between DCM direction {dcm_direction} and ITK direction {itk_direction}" + ) + + else: + image.SetOrigin(dcm_data.ImagePositionPatient) + image.SetDirection(list(dcm_data.ImageOrientationPatient) + [0, 0, 1]) + + return image + + +def squeeze_sitk_image_dimension( + img: SimpleITK.Image, dim: int = 3, slice_index: int = 0 +) -> SimpleITK.Image: + """ + Remove a singleton dimension from a SimpleITK image, like numpy.squeeze. + + Parameters + ---------- + img : SimpleITK.Image + Your input image (e.g. a 4D volume with size (Nx, Ny, Nz, 1)). + dim : int + The zero-based dimension to remove (for (Nx,Ny,Nz,1), dim=3). + slice_index : int + Which slice along that dimension to keep (must be < img.GetSize()[dim]). + + Returns + ------- + squeezed : SimpleITK.Image + A new image with one fewer dimension (e.g. (Nx,Ny,Nz)). + """ + # 0) If image is 2-D, error; if image is 3-D, nothing to do. + if img.GetDimension() < 3: + raise AssertionError( + f"Image Dimensions are not valid: dim={img.GetDimension()}, size={img.GetSize()}" + ) + + if img.GetDimension() == 3: + return img + + # 1) build size vector, set the target dim to 0 => collapse it + size = list(img.GetSize()) + size[dim] = 0 + + # 2) build index vector, pick which slice of the dropped dim you want + index = [0] * img.GetDimension() + index[dim] = slice_index + + # 3) run the extractor + extractor = SimpleITK.ExtractImageFilter() + extractor.SetSize(size) + extractor.SetIndex(index) + + return extractor.Execute(img) + + +def load_from_dicom_dir( + dir: str, modality: str, calibration_factor: Optional[float] = None +) -> Tuple[Image, ImagingMetadata]: + """Load CT or SPECT data from DICOM files in the specified folder. + + Returns the Image object and some relevant metadata. + + Args: + dir (str): Directory path containing DICOM files. + modality (str): The imaging modality. + calibration_factor (str, optional): Factor to scale SPECT voxel values (e.g., could be SPECT calibration Factor in BQ/CPS or dimensionless factor) + + Returns + ------- + Tuple[Image, ImagingMetadata] + Tuple containing the Image object and metadata. + """ + # Read image content and spatial information using SimpleITK + image = sitk_load_dcm_series(dcm_dir=Path(dir)) + + # If Q-SPECT, need to re-scale Data and possibly add Origin/Direction: + if modality != "CT": + + # Remove redundant dimension + image = squeeze_sitk_image_dimension(img=image) + image = apply_qspect_dcm_origin(image=image, dir=dir) + + # QSPECT - Uses scale_factor provided by user, or attempts to get it from DICOM (if QSPECT) + scale_factor = None + + if calibration_factor is not None: + scale_factor = (calibration_factor, 0) + + try: + image = apply_qspect_dcm_scaling( + image=image, dir=dir, scale_factor=scale_factor + ) + + except AttributeError: + print("No calibration factor provided, Data might not be in BQ/ML ...") + + # Load Meta Data using pydicom. + meta = load_metadata(dir=dir, modality=modality) + + # Force Orthogonality of Patient Orientation + image = force_orthogonality(image=image) + + # Display Origin and Orientation. + print( + f"Modality: {modality} -> Origin: {image.GetOrigin()}; Direction: {image.GetDirection()}" + ) + + return image, meta + + +def are_vectors_orthogonal(origin: List[float], tol: float = 1e-24): + """Check if the patient orientation is given by orthogonal vectors. + + Returns True if a·b, a·c, and b·c are all within ±tol; otherwise False. + + Parameters + ---------- + origin : List[float] + Coordinates of Patient Orientation + tol : float, optional + Tolerance, by default 1e-8 + + Returns + ------- + _type_ + _description_ + """ + # split into three 3D vectors + a = origin[0:3] + b = origin[3:6] + c = origin[6:9] + + def dot(u, v): + return sum(ui * vi for ui, vi in zip(u, v)) + + return abs(dot(a, b)) < tol and abs(dot(a, c)) < tol and abs(dot(b, c)) < tol + + +def force_orthogonality(image: SimpleITK.Image) -> SimpleITK.Image: + """Force orthogonality of patient orientation vectors. + + Parameters + ---------- + image : SimpleITK.Image + Input image. + + Returns + ------- + SimpleITK.Image + Image with orthogonal orientation vectors. + """ + if not are_vectors_orthogonal(image.GetDirection()): + print("Patient Orientation Vectors are NOT orthogonal. Forcing...") + prev_origin = image.GetDirection() + new_origin = [round(vec_element) for vec_element in prev_origin] + print(f">> Original Orientation: {prev_origin}, New Orientation: {new_origin} ") + image.SetDirection(new_origin) + else: + prev_origin = image.GetDirection() + new_origin = [round(vec_element) for vec_element in prev_origin] + + return image + + +def load_RTStruct( + ref_dicom_ct_dir: str, rt_struct_file: str +) -> Dict[str, SimpleITK.Image]: + """Load RTStruct Contours and Generate Masks. + + Parameters + ---------- + ref_dicom_ct_dir : str + Path to reference Dicom dir of CT slices associated with RTStruct + rt_struct_file : str + Path to RTStruct file. + + Returns + ------- + Dict[str, SimpleITK.Image] + A Dictionary containing each mask present in the RTStruct file. + """ + + def clean_roi_name(roi_name: str) -> str: + cleaned_roi_name = ( + roi_name.replace(" ", "") + .replace("-", "_") + .replace("(", "_") + .replace(")", "") + ) + return cleaned_roi_name + + CT_folder = Path(ref_dicom_ct_dir) + + if not CT_folder.exists(): + raise FileNotFoundError(f"Folder {CT_folder.name} does not exists.") + + CT_sitk = force_orthogonality(image=sitk_load_dcm_series(dcm_dir=CT_folder)) + + RT = RTStructBuilder.create_from( + dicom_series_path=ref_dicom_ct_dir, rt_struct_path=rt_struct_file + ) + + roi_masks: Dict[str, SimpleITK.Image] = {} + roi_names = RT.get_roi_names() + + # Clean names, as they might come with unsupported characters from third party software. + for roi_name in roi_names: + cleaned_roi_name = clean_roi_name(roi_name) + mask = RT.get_roi_mask_by_name(roi_name) + roi_masks[cleaned_roi_name] = itk_image_from_array( + array=numpy.transpose(mask, axes=(2, 0, 1)), ref_image=CT_sitk, is_mask=True + ) + + return roi_masks + + +def resample_mask_to_target( + mask_img: SimpleITK.Image, target_img: SimpleITK.Image +) -> SimpleITK.Image: + """ + Resample a binary mask (originally from CT) to match a target ITK image in physical space (location/voxel spacing). + + Parameters + ---------- + mask_img : SimpleITK.Image + Binary CT mask (e.g. sitkUInt8 or sitkUInt16). + target_img : SimpleITK.Image + The reference image (SPECT) whose spacing, origin, + direction, and size you want to match. + + Returns + ------- + resampled_mask_img : SimpleITK.Image + The mask resampled into the target image's space. + resampled_mask_array : np.ndarray + A NumPy array of shape (z, y, x) aligned exactly with + the SimpleITK target image. + """ + # Quick geometry check: if mask and target already match, skip resampling + if ( + mask_img.GetSize() == target_img.GetSize() + and mask_img.GetSpacing() == target_img.GetSpacing() + and mask_img.GetOrigin() == target_img.GetOrigin() + and mask_img.GetDirection() == target_img.GetDirection() + ): + return mask_img + + # ensure mask is of an integer type suitable for NN interpolation + mask_cast = SimpleITK.Cast(mask_img, SimpleITK.sitkUInt8) + + resampler = SimpleITK.ResampleImageFilter() + # copy geometry from target + resampler.SetReferenceImage(target_img) + # nearest‐neighbor to keep it binary + resampler.SetInterpolator(SimpleITK.sitkNearestNeighbor) + resampler.SetDefaultPixelValue(0) + + return resampler.Execute(mask_cast) + + +def load_and_resample_RT_to_target( + ref_dicom_ct_dir: str, rt_struct_file: str, target_img: SimpleITK.Image +) -> Tuple[Dict[str, SimpleITK.Image], Dict[str, SimpleITK.Image]]: + """Load and resample RT structure to target image. + + Parameters + ---------- + ref_dicom_ct_dir : str + Directory containing reference DICOM CT files. + rt_struct_file : str + Path to RT structure file. + target_img : SimpleITK.Image + Target image for resampling. + + Returns + ------- + Tuple[Dict[str, SimpleITK.Image], Dict[str, SimpleITK.Image]] + Reference (CT) and Resampleda (SPECT) Masks from RTStruct. + """ + ref_masks = load_RTStruct( + ref_dicom_ct_dir=ref_dicom_ct_dir, rt_struct_file=rt_struct_file + ) + + resampled_masks: Dict[str, SimpleITK.Image] = {} + + for mask_name, mask_image in ref_masks.items(): + print(f"Resampling Masks: {mask_name} ...") + resampled_masks[mask_name] = resample_mask_to_target( + mask_img=mask_image, target_img=target_img + ) + + return ref_masks, resampled_masks + + +def load_and_register_RT_to_target( + ref_dicom_ct_dir: str, rt_struct_file: str, target_img: SimpleITK.Image +) -> Tuple[Dict[str, SimpleITK.Image], Dict[str, SimpleITK.Image]]: + """Load and register RT structure to target image. + + Parameters + ---------- + ref_dicom_ct_dir : str + Directory containing reference DICOM CT files. + rt_struct_file : str + Path to RT structure file. + target_img : SimpleITK.Image + _description_ + + Returns + ------- + Tuple[Dict[str, SimpleITK.Image], Dict[str, SimpleITK.Image]] + Reference (CT) and Resampleda (SPECT) Masks from RTStruct. + """ + ref_masks = load_RTStruct( + ref_dicom_ct_dir=ref_dicom_ct_dir, rt_struct_file=rt_struct_file + ) + ref_ct, _ = load_from_dicom_dir(dir=ref_dicom_ct_dir, modality="CT") + + ref_ct = SimpleITK.Cast(ref_ct, SimpleITK.sitkFloat32) + target_img = SimpleITK.Cast(target_img, SimpleITK.sitkFloat32) + + # Register: + _, transform = register_ct_to_spect(ct_image=ref_ct, spect_image=target_img) + + resampled_masks: Dict[str, SimpleITK.Image] = {} + + for mask_name, mask_image in ref_masks.items(): + print(f"Registering Masks: {mask_name} ...") + resampled_masks[mask_name] = transform_ct_mask_to_spect( + mask=mask_image, spect=target_img, transform=transform + ) + + return ref_masks, resampled_masks + + +def resample_to_target( + source_img: SimpleITK.Image, + target_img: SimpleITK.Image, + default_value: float = -1000.0, +) -> SimpleITK.Image: + """Resample source_img to match the grid of target_image. + + Matches origin, spacing, direction, and size of target_image using the + SimpleITK Linear interpolator. + + Parameters + ---------- + source_img : sitk.Image + The image to be resampled. + target_img : sitk.Image + The reference image defining the desired grid. + default_value : float + Pixel value for voxels outside source_img domain. Defaults to CT air values. + + Returns + ------- + sitk.Image + The resampled image. + """ + # Set up the resampler + resampler = SimpleITK.ResampleImageFilter() + resampler.SetReferenceImage(target_img) + resampler.SetInterpolator(SimpleITK.sitkLinear) + resampler.SetDefaultPixelValue(default_value) + + # Use an identity transform to align in physical space + identity = SimpleITK.Transform(source_img.GetDimension(), SimpleITK.sitkIdentity) + resampler.SetTransform(identity) + + # Perform resampling + resampled_img = resampler.Execute(source_img) + return resampled_img + + +def ensure_masks_disconnect( + original_masks: Dict[str, numpy.ndarray], +) -> Dict[str, numpy.ndarray]: + """Ensure masks are disconnected by resolving overlaps. + + Args: + original_masks (Dict[str, numpy.ndarray]): Dictionary of mask arrays. + + Returns + ------- + Dict[str, numpy.ndarray] + Dictionary of disconnected masks. + """ + if len(original_masks) == 0: + return original_masks + + # Create multi-label array from all masks. Each mask is a different ID, overwriting the previous one if there are overlaps. + original_masks_names = [region for region in original_masks.keys()] + all_original_mask = numpy.zeros( + original_masks[original_masks_names[0]].shape, dtype=numpy.int16 + ) + + id = 1 + final_regions: List[str] = [] + for region, mask in original_masks.items(): + all_original_mask[numpy.where(mask)] = id + final_regions.append(region) + id += 1 + + # Split array into individual masks arrays. + final_masks: Dict[str, numpy.ndarray] = {} + for id_final in range(1, id): + final_masks[final_regions[id_final - 1]] = numpy.where( + all_original_mask == id_final, True, False + ) + + return final_masks + + +def extract_masks( + time_id: int, + mask_dataset: Dict[int, Dict[str, numpy.ndarray]], + requested_rois: List[str], +) -> Dict[str, numpy.ndarray]: + """Extract masks from NM dataset, according to user-defined list. Enforce that masks are disconnected. + + Constrains: + - Tumors are always going to be removed from organs. + - For non-tumor regions with overlapping voxels, the newly added region will prevail. + + Returns + ------- + Dict[str, numpy.ndarray] + Dictionary of compliant masks. + """ + # Available Mask Names: + exclude = ["WholeBody", "RemainderOfBody"] + if "BoneMarrow" not in mask_dataset[0]: + exclude.append("BoneMarrow") + + mask_names = [name for name in requested_rois if name not in exclude] + + # Disconnect tumor masks (if there is any overlap among them) + tumor_labels = [region for region in requested_rois if "Lesion" in region] + tumors_masks = ensure_masks_disconnect( + original_masks={ + tumor_label: mask_dataset[time_id][tumor_label] + for tumor_label in tumor_labels + } + ) + + # Get mask of total tumor burden + tumor_burden_mask = numpy.zeros_like(mask_dataset[time_id][requested_rois[0]]) + + for _, tumor_mask in tumors_masks.items(): + tumor_burden_mask[numpy.where(tumor_mask)] = True + + # Remove tumor from normal tissue regions. + non_tumor_masks_aggregate: Dict[str, numpy.ndarray] = { + region: ( + numpy.clip( + (mask_dataset[time_id][region]).astype(numpy.int8) + - tumor_burden_mask.astype(numpy.int8), + 0, + 1, + ) + ).astype(bool) + for region in mask_names + if region not in tumor_labels + } + + corrected_masks = ensure_masks_disconnect(original_masks=non_tumor_masks_aggregate) + corrected_masks.update(tumors_masks) + + # Generate Remainder of Body Mask: + remainder = ( + numpy.ones(tumor_burden_mask.shape, dtype=numpy.int8) + if "WholeBody" not in mask_dataset[time_id].keys() + else mask_dataset[time_id]["WholeBody"].astype(numpy.int8) + ) + + for _, mask in corrected_masks.items(): + remainder -= mask + + corrected_masks["RemainderOfBody"] = ( + numpy.clip(remainder, 0, 1) != 0 + ) # Cast to boolean. + + # Generate Whole Body Mask: + whole_body = numpy.zeros_like(remainder) + for _, mask in corrected_masks.items(): + whole_body += mask + + corrected_masks["WholeBody"] = numpy.clip(whole_body, 0, 1) != 0 # Cast to boolean. + + return corrected_masks + + +def jaccard_index(mask_1: numpy.ndarray, mask_2: numpy.ndarray) -> float: + """Compute the Jaccard index between two binary masks. + + Args: + mask_1 (numpy.ndarray): First binary mask as a numpy array where 1s represent the mask and 0s represent the background. + mask_2 (numpy.ndarray): Second binary mask as a numpy array similar to mask_1. + + Returns + ------- + float + Jaccard index value. + """ + intersection = numpy.logical_and(mask_1, mask_2) + union = numpy.logical_or(mask_1, mask_2) + jaccard = numpy.sum(intersection) / numpy.sum(union) + + return jaccard diff --git a/pytheranostics/misc_tools/__init__.py b/pytheranostics/misc_tools/__init__.py new file mode 100644 index 0000000..1311fff --- /dev/null +++ b/pytheranostics/misc_tools/__init__.py @@ -0,0 +1,6 @@ +"""Miscellaneous tools and utilities. + +PEP 8 compliant package with lowercase module names. +""" + +__all__ = ["tools", "report_generator"] diff --git a/pytheranostics/misc_tools/report_generator.py b/pytheranostics/misc_tools/report_generator.py new file mode 100644 index 0000000..ce78b7c --- /dev/null +++ b/pytheranostics/misc_tools/report_generator.py @@ -0,0 +1,714 @@ +"""Report generation utilities for dosimetry analysis.""" + +import glob +import json +from datetime import datetime +from pathlib import Path + +from reportlab.lib import colors +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import ( + Image, + KeepTogether, + PageBreak, + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, +) + + +def signature_block(person, styles, width=2.5 * inch, height=0.6 * inch): + """Create a stable signature block for PDF reports. + + Placeholder for signature, line, and text. + Returns a small table that can be inserted side-by-side with others. + """ + elements = [] + + # If a signature path is provided, insert the image + if person.get("signature"): + sig_img = Image(person["signature"]) + # Scale the image to fit width and maintain aspect ratio + sig_img.drawHeight = height + sig_img.drawWidth = width + elements.append(sig_img) + else: + # Empty space if no signature image + elements.append(Spacer(1, height)) + + # Line row + line = Table([[""]], colWidths=[width]) + line.setStyle(TableStyle([("LINEABOVE", (0, 0), (-1, -1), 1, colors.black)])) + elements.append(line) + + # Text row + text = Paragraph( + f"{person['name']}
{person['title']}
{person['affiliation']}
", + styles["Normal"], + ) + elements.append(text) + + # Wrap everything in a column table (1 col, stacked) + block = Table([[e] for e in elements], colWidths=[width]) + block.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "CENTER")])) + return block + + +def create_dosimetry_pdf( + image_bar, + json_file, + output_file, + calculated_by=None, + approved_by=None, + comment=None, +): + """Generate a dosimetry report PDF from patient JSON data. + + Parameters + ---------- + json_file : str or Path + Path to the patient's JSON file. + output_file : str or Path + Path to save the generated PDF report. + calculated_by : list of dict, optional + List of dictionaries with keys 'name', 'title', 'affiliation' for those who calculated the doses. + approved_by : list of dict, optional + List of dictionaries with keys 'name', 'title', 'affiliation' for those who approved the report. + """ + # Load JSON data + with open(json_file, "r") as file: + data = json.load(file) + + # Create PDF document + doc = SimpleDocTemplate(output_file, pagesize=letter) + elements = [] + styles = getSampleStyleSheet() + + # Title + title = Paragraph( + "DOSIMETRY ASSESSMENT", styles["Title"] + ) + elements.append(title) + elements.append(Spacer(1, 0.5 * inch)) + + # Subject Information Section + elements.append(Paragraph("Subject Information", styles["Heading2"])) + # Subject Information Table + subject_data = [ + ["Clinical Trial", data.get("ClinicalTrial")], + ["Radiopharmaceutical", "177Lu-PSMA-617"], + ["Mode of administration", "I.V."], + ["ID", data.get("PatientID")], + ["Sex", data.get("Gender")], + ["Weight kg", data.get("Cycle_01", {})[0].get("Weight_g", "") / 1000], + ["Height cm", data.get("Cycle_01", {})[0].get("Height_cm", "")], + ["Number of cycles ", data.get("No_of_completed_cycles")], + ] + + subject_table = Table(subject_data, colWidths=[2 * inch, 3.5 * inch]) + subject_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTNAME", (0, 0), (-1, -1), "Helvetica"), + ("FONTSIZE", (0, 0), (-1, -1), 12), + ("GRID", (0, 0), (-1, -1), 1, colors.black), + ("BACKGROUND", (0, 0), (0, -1), colors.lightgrey), + ] + ) + ) + + elements.append(subject_table) + elements.append(Spacer(1, 0.3 * inch)) + if comment: + elements.append( + Paragraph(f'{comment}', styles["Normal"]) + ) + elements.append(Spacer(1, 0.3 * inch)) + elements.append( + Paragraph("Maximum Intensity Projection", styles["Heading3"]) + ) + + calling_folder = Path().absolute() # notebook folder + mip_images = [] + max_width = 8 * inch + max_height = 6 * inch + + for i in range(1, data.get("No_of_completed_cycles") + 1): + pattern = calling_folder / f"TestDoseDB/MIP_tp*_Cycle_0{i}.png" + matches = sorted(glob.glob(str(pattern))) # find all matches for this cycle + + for match in matches: + img = Image(match) + scale = min(max_width / img.imageWidth, max_height / img.imageHeight) / 2 + img.drawWidth = img.imageWidth * scale + img.drawHeight = img.imageHeight * scale + mip_images.append(img) + + # Add colorbar as the last "image" in the same row + colorbar_path = calling_folder / "TestDoseDB/bar.png" + + if colorbar_path.exists(): + bar_img = Image(str(colorbar_path)) + + # Make the bar much narrower (thin horizontal line) + bar_max_width = 2.7 * inch + bar_max_height = 2.4 * inch + + bar_scale = min( + bar_max_width / bar_img.imageWidth, bar_max_height / bar_img.imageHeight + ) + + bar_img.drawWidth = bar_img.imageWidth * bar_scale + bar_img.drawHeight = bar_img.imageHeight * bar_scale + + mip_images.append(bar_img) # <-- add as last image + + # Put all images in one row using a Table + if mip_images: + mip_table = Table([mip_images]) # single row + mip_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + elements.append(mip_table) + + # Add caption below + elements.append(Spacer(1, 0.2 * inch)) + caption = Paragraph( + "Figure 1: Maximum Intensity Projection images of the patient across cycles. " + "The regions show the segmented organs at risk including the kidneys and the salivary glands. " + f"The maximum value threshold set in all images at {image_bar/1000} kBq/ml. ", + styles["Normal"], + ) + elements.append(caption) + elements.append(Spacer(1, 0.2 * inch)) + + elements.append(PageBreak()) + + for i in range(1, data.get("No_of_completed_cycles") + 1): + cycle_info(i, elements, styles, data) + + elements.append(PageBreak()) + + elements.append(Paragraph("Patient Summary", styles["Heading2"])) + + # =============================== + # CUMULATIVE ORGAN TABLE + # =============================== + elements.append( + Paragraph("Cumulative Absorbed Dose Summary", styles["Heading3"]) + ) + + total_tia_kidneys = 0 + total_tia_salivary = 0 + total_tia_marrow = 0 + total_tia_liver = 0 + total_tia_spleen = 0 + total_tia_body = 0 + + total_ad_kidneys = 0 + total_ad_salivary = 0 + total_ad_marrow = 0 + total_ad_liver = 0 + total_ad_spleen = 0 + total_ad_body = 0 + + total_bed_kidneys = 0 + + for i in range(1, data.get("No_of_completed_cycles") + 1): + therapy_info = data.get(f"Cycle_0{i}", {})[0] + + # Kidneys + kidney_labels = [k for k in therapy_info["VOIs"] if k.startswith("Kidney_")] + + for k in kidney_labels: + total_tia_kidneys += therapy_info["VOIs"][k]["TIA_h"] + + total_ad_kidneys += therapy_info["Organ-level_AD"]["Kidneys"]["AD[Gy]"] + total_bed_kidneys += therapy_info["Organ-level_AD"]["Kidneys"]["BED[Gy]"] + + # Red marrow + total_tia_marrow += therapy_info["VOIs"]["BoneMarrow"]["TIA_h"] + total_ad_marrow += therapy_info["Organ-level_AD"]["Red Marrow"]["AD[Gy]"] + + # Salivary glands + total_tia_salivary += ( + therapy_info["VOIs"]["ParotidGland_Left"]["TIA_h"] + + therapy_info["VOIs"]["ParotidGland_Right"]["TIA_h"] + + therapy_info["VOIs"]["SubmandibularGland_Left"]["TIA_h"] + + therapy_info["VOIs"]["SubmandibularGland_Right"]["TIA_h"] + ) + total_ad_salivary += therapy_info["Organ-level_AD"]["Salivary Glands"]["AD[Gy]"] + + # Liver + total_tia_liver += therapy_info["VOIs"]["Liver"]["TIA_h"] + total_ad_liver += therapy_info["Organ-level_AD"]["Liver"]["AD[Gy]"] + + # Spleen + total_tia_spleen += therapy_info["VOIs"]["Spleen"]["TIA_h"] + total_ad_spleen += therapy_info["Organ-level_AD"]["Spleen"]["AD[Gy]"] + + # Body + total_tia_body += therapy_info["VOIs"]["WholeBody"]["TIA_h"] + total_ad_body += therapy_info["Organ-level_AD"]["Total Body"]["AD[Gy]"] + + # Build the cumulative table + cumulative_data = [ + ["Organ", "Cumulative TIA (h)", "Cumulative AD (Gy)", "Cumulative BED (Gy)"], + [ + "Kidneys", + round(total_tia_kidneys, 2), + round(total_ad_kidneys, 2), + round(total_bed_kidneys, 2), + ], + ["Red Marrow", round(total_tia_marrow, 2), round(total_ad_marrow, 2), "-"], + [ + "Salivary glands", + round(total_tia_salivary, 2), + round(total_ad_salivary, 2), + "-", + ], + ["Liver", round(total_tia_liver, 2), round(total_ad_liver, 2), "-"], + ["Spleen", round(total_tia_spleen, 2), round(total_ad_spleen, 2), "-"], + ["Total Body", round(total_tia_body, 2), round(total_ad_body, 2), "-"], + ] + + cumulative_table = Table(cumulative_data, colWidths=[1.5 * inch, 1.7 * inch]) + cumulative_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTNAME", (0, 0), (-1, -1), "Helvetica"), + ("FONTSIZE", (0, 0), (-1, -1), 12), + ("GRID", (0, 0), (-1, -1), 1, colors.black), + ("BACKGROUND", (0, 0), (0, -1), colors.lightblue), + ] + ) + ) + + elements.append(cumulative_table) + + # Paths + plot_paths = [ + calling_folder / "TestDoseDB/Gy_per_GBq_per_cycle.png", + calling_folder / "TestDoseDB/Gy_cumulative.png", + ] + legend_path = calling_folder / "TestDoseDB/AD_legend.png" + + # ---- Load plots (row 1) ---- + plots = [] + for path in plot_paths: + img = Image(str(path)) + + scale = min( + (max_width * 0.45) / img.imageWidth, # half width + (max_height / 2) / img.imageHeight, + ) + + img.drawWidth = img.imageWidth * scale + img.drawHeight = img.imageHeight * scale + plots.append(img) + + # ---- Load legend (row 2, scaled to 70%) ---- + legend = Image(str(legend_path)) + + legend_scale = ( + min( + (max_width * 0.9) / legend.imageWidth, + (max_height / 4) / legend.imageHeight, + ) + * 0.5 + ) # 70% size + + legend.drawWidth = legend.imageWidth * legend_scale + legend.drawHeight = legend.imageHeight * legend_scale + + # ---- Build table data ---- + table_data = [ + [plots[0], plots[1]], # Row 1: two plots + [legend, ""], # Row 2: legend spanning 2 columns + ] + + table = Table( + table_data, + colWidths=[max_width * 0.45, max_width * 0.45], + ) + + table.setStyle( + TableStyle( + [ + ("SPAN", (0, 1), (1, 1)), # merge legend row + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + + elements.append(Spacer(1, 0.3 * inch)) + elements.append(table) + + # Add caption + elements.append(Spacer(1, 0.2 * inch)) + caption = Paragraph( + "Figure 2: Absorbed dose per cycle reported in units of Gy/GBq, and cumulative absorbed dose in Gy for target organs and total tumor burden (TTB).", + styles["Normal"], + ) + elements.append(caption) + elements.append(Spacer(1, 0.2 * inch)) + + # new page + elements.append(PageBreak()) + elements.append(Paragraph("Laboratory Results Summary", styles["Heading3"])) + + # Paths to trend plots + trend_paths = [ + calling_folder / "TestDoseDB/Hemoglobin_trend.png", + calling_folder / "TestDoseDB/Platelets_trend.png", + calling_folder / "TestDoseDB/eGFR_trend.png", + calling_folder / "TestDoseDB/PSA_trend.png", + calling_folder / "TestDoseDB/CTCAE_legend.png", + ] + + trend_imgs = [] + for i, path in enumerate(trend_paths): + img = Image(str(path)) + + # scale plots and legend slightly differently + if i < 4: # regular plots + scale = min( + (max_width / 2.5) / img.imageWidth, + (max_height / 2.5) / img.imageHeight, + ) + else: # legend – wider, less tall + scale = min( + (max_width / 1.5) / img.imageWidth, + (max_height / 6) / img.imageHeight, + ) + + img.drawWidth = img.imageWidth * scale + img.drawHeight = img.imageHeight * scale + trend_imgs.append(img) + + # ---- Table layout ---- + trend_table_data = [ + [trend_imgs[0], trend_imgs[1]], + [trend_imgs[2], trend_imgs[3]], + [trend_imgs[4], ""], # legend row + ] + + trend_table = Table( + trend_table_data, + colWidths=[max_width / 2.5, max_width / 2.5], + ) + + trend_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + # merge legend row across both columns + ("SPAN", (0, 2), (1, 2)), + # spacing + ("TOPPADDING", (0, 0), (-1, -1), 12), + ("BOTTOMPADDING", (0, 0), (-1, -1), 12), + ] + ) + ) + + elements.append(trend_table) + + # Add caption + caption = Paragraph( + "Figure 3: Trends of hematological and renal function, and PSA.", + styles["Normal"], + ) + elements.append(caption) + elements.append(PageBreak()) + # =============================== + # Signatures Section + # =============================== + elements.append(Spacer(1, 0.5 * inch)) + team_title = Paragraph("Signature Section", styles["Heading2"]) + elements.append(team_title) + + # --- Calculated by --- + elements.append(Spacer(1, 0.3 * inch)) + elements.append( + Paragraph("The absorbed doses were calculated by:", styles["Normal"]) + ) + + if calculated_by: + calc_blocks = [signature_block(p, styles) for p in calculated_by] + calc_table = Table( + [calc_blocks], colWidths=[doc.width / len(calc_blocks)] * len(calc_blocks) + ) + elements.append(calc_table) + + # --- Approved by --- + elements.append(Spacer(1, 0.5 * inch)) + elements.append( + Paragraph("The results were reviewed and approved by:", styles["Normal"]) + ) + + if approved_by: + app_blocks = [signature_block(p, styles) for p in approved_by] + app_table = Table( + [app_blocks], colWidths=[doc.width / len(app_blocks)] * len(app_blocks) + ) + elements.append(app_table) + + # =============================== + # Appendix + # =============================== + + elements.append(PageBreak()) + title = Paragraph("Appendix", styles["Heading2"]) + elements.append(title) + fig_title = Paragraph( + "Biodistribution and kinetic analysis", + styles["Heading3"], + ) + elements.append(fig_title) + + for i in range(1, data.get("No_of_completed_cycles") + 1): + biodistribution_per_cycle(i, elements, styles, data) + + # Build PDF + doc.build(elements) + + +def biodistribution_per_cycle(cycle_n, elements, styles, data): + """Add cycle information to the PDF report elements. + + Parameters + ---------- + cycle_n : int + The cycle number. + elements : list + List of reportlab elements to append to. + styles : dict + ReportLab styles dictionary. + data : dict + Patient data dictionary. + """ + if cycle_n == 1: + pass + else: + elements.append(PageBreak()) + + therapy_title = Paragraph(f"Cycle {cycle_n}", styles["Heading2"]) + elements.append(therapy_title) + + page_width, page_height = letter + max_width = page_width - 2 * 72 # 1-inch margins + max_height = page_height - 2 * 72 + + calling_folder = Path().absolute() # notebook folder + pattern = calling_folder / f"TestDoseDB/*_fit_Cycle_0{cycle_n}.png" + + # glob returns a list of matching files + image_paths = glob.glob(str(pattern)) + image_paths = sorted(image_paths) + + for image_path in image_paths: + if "Lesion" in Path(image_path).name: + continue + if "Bladder" in Path(image_path).name: + continue + if "Skeleton" in Path(image_path).name: + continue + if "Remainder" in Path(image_path).name: + continue + img = Image(image_path) + + # Compute scaling factor to fit inside page + scale = min(max_width / img.imageWidth, max_height / img.imageHeight) + + # Apply scaling (preserve aspect ratio) + img.drawWidth = img.imageWidth * scale + img.drawHeight = img.imageHeight * scale + + elements.append(Spacer(1, 0.3 * inch)) + elements.append(img) + + +def cycle_info(cycle_n, elements, styles, data): + """Add cycle information to the PDF report elements. + + Parameters + ---------- + cycle_n : int + The cycle number. + elements : list + List of reportlab elements to append to. + styles : dict + ReportLab styles dictionary. + data : dict + Patient data dictionary. + """ + # Therapy Information Section + # therapy_title = Paragraph(f"Cycle {cycle_n}", styles["Heading2"]) + # elements.append(therapy_title) + + # Therapy Information Table + therapy_info = data.get(f"Cycle_0{cycle_n}", {}) + + # Format injection date nicely + inj_date_raw = therapy_info[0].get("InjectionDate", "") + inj_date = ( + datetime.strptime(inj_date_raw, "%Y%m%d").strftime("%Y-%m-%d") + if inj_date_raw + else "" + ) + + # Build a clean paragraph instead of a table + therapy_info_para = Paragraph( + f"" + f"Administered activity: {therapy_info[0].get('InjectedActivity', '')} MBq
" + f"Date of injection: {inj_date}" + f"
", + styles["Normal"], + ) + + # Add to document + # elements.append(therapy_info_para) + # elements.append(Spacer(1, 0.089 * inch)) + + # fig_title = Paragraph( + # "Absorbed dose results for the organs at risk", styles["Heading3"] + # ) + # elements.append(fig_title) + cycle_header_block = KeepTogether( + [ + Paragraph(f"Cycle {cycle_n}", styles["Heading2"]), + therapy_info_para, + Spacer(1, 0.089 * inch), + Paragraph( + "Absorbed dose results for the organs at risk", + styles["Heading3"], + ), + ] + ) + + elements.append(cycle_header_block) + + organ_data_Gy_GBq = [ + ["Organ", "TIA (h)", "Mass (g)", "AD (Gy/GBq)", "AD (Gy)", "BED (Gy)"], + [ + "Kidneys", + round( + sum( + therapy_info[0]["VOIs"][k]["TIA_h"] + for k in therapy_info[0]["VOIs"] + if k.startswith("Kidney_") + ), + 2, + ), + round( + sum( + therapy_info[0]["VOIs"][k]["volumes_mL"]["mean"] + for k in therapy_info[0]["VOIs"] + if k.startswith("Kidney_") + ), + 2, + ), + round(therapy_info[0]["Organ-level_AD"]["Kidneys"]["AD[Gy/GBq]"], 2), + round(therapy_info[0]["Organ-level_AD"]["Kidneys"]["AD[Gy]"], 2), + round(therapy_info[0]["Organ-level_AD"]["Kidneys"]["BED[Gy]"], 2), + ], + [ + "Red Marrow", + round((therapy_info[0]["VOIs"]["BoneMarrow"]["TIA_h"]), 2), + round(therapy_info[0]["VOIs"]["BoneMarrow"]["volumes_mL"]["mean"], 2), + round(therapy_info[0]["Organ-level_AD"]["Red Marrow"]["AD[Gy/GBq]"], 2), + round(therapy_info[0]["Organ-level_AD"]["Red Marrow"]["AD[Gy]"], 2), + "-", + ], + [ + "Salivary glands", + round( + ( + therapy_info[0]["VOIs"]["ParotidGland_Left"]["TIA_h"] + + therapy_info[0]["VOIs"]["ParotidGland_Right"]["TIA_h"] + + therapy_info[0]["VOIs"]["SubmandibularGland_Left"]["TIA_h"] + + therapy_info[0]["VOIs"]["SubmandibularGland_Right"]["TIA_h"] + ), + 2, + ), + round( + ( + therapy_info[0]["VOIs"]["ParotidGland_Left"]["volumes_mL"]["mean"] + + therapy_info[0]["VOIs"]["ParotidGland_Right"]["volumes_mL"][ + "mean" + ] + + therapy_info[0]["VOIs"]["SubmandibularGland_Left"]["volumes_mL"][ + "mean" + ] + + therapy_info[0]["VOIs"]["SubmandibularGland_Right"]["volumes_mL"][ + "mean" + ] + ), + 2, + ), + round( + therapy_info[0]["Organ-level_AD"]["Salivary Glands"]["AD[Gy/GBq]"], 2 + ), + round(therapy_info[0]["Organ-level_AD"]["Salivary Glands"]["AD[Gy]"], 2), + "-", + ], + [ + "Liver", + round((therapy_info[0]["VOIs"]["Liver"]["TIA_h"]), 2), + round(therapy_info[0]["VOIs"]["Liver"]["volumes_mL"]["mean"], 2), + round(therapy_info[0]["Organ-level_AD"]["Liver"]["AD[Gy/GBq]"], 2), + round(therapy_info[0]["Organ-level_AD"]["Liver"]["AD[Gy]"], 2), + "-", + ], + [ + "Spleen", + round((therapy_info[0]["VOIs"]["Spleen"]["TIA_h"]), 2), + round(therapy_info[0]["VOIs"]["Spleen"]["volumes_mL"]["mean"], 2), + round(therapy_info[0]["Organ-level_AD"]["Spleen"]["AD[Gy/GBq]"], 2), + round(therapy_info[0]["Organ-level_AD"]["Spleen"]["AD[Gy]"], 2), + "-", + ], + [ + "Total Body", + round((therapy_info[0]["VOIs"]["WholeBody"]["TIA_h"]), 2), + round(therapy_info[0]["VOIs"]["WholeBody"]["volumes_mL"]["mean"], 2), + round(therapy_info[0]["Organ-level_AD"]["Total Body"]["AD[Gy/GBq]"], 2), + round(therapy_info[0]["Organ-level_AD"]["Total Body"]["AD[Gy]"], 2), + "-", + ], + ] + organ_table_Gy_GBq = Table(organ_data_Gy_GBq, colWidths=[1.5 * inch, 1.15 * inch]) + organ_table_Gy_GBq.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTNAME", (0, 0), (-1, -1), "Helvetica"), + ("FONTSIZE", (0, 0), (-1, -1), 12), + ("GRID", (0, 0), (-1, -1), 1, colors.black), + ("BACKGROUND", (0, 0), (0, -1), colors.lightgrey), + ] + ) + ) + + elements.append(organ_table_Gy_GBq) + elements.append(Spacer(1, 0.3 * inch)) diff --git a/pytheranostics/misc_tools/tools.py b/pytheranostics/misc_tools/tools.py new file mode 100644 index 0000000..b582f3b --- /dev/null +++ b/pytheranostics/misc_tools/tools.py @@ -0,0 +1,514 @@ +"""Miscellaneous utility tools for image processing and analysis.""" + +from datetime import datetime +from typing import Dict, Tuple + +import matplotlib.pyplot as plt +import numpy +from scipy.ndimage import median_filter + + +def hu_to_rho(hu: numpy.ndarray) -> numpy.ndarray: + """Convert a CT array in HU into a density map in g/cc. + + Conversion based on Schneider et al. 2000 (using GATE's material db example). + + Args: + hu (numpy.ndarray): CT array in Hounsfield Units. + + Returns + ------- + numpy.ndarray + Density map in g/cc. + """ + # Define the bin edges for HU values + bins = numpy.array( + [ + -1050, + -950, + -852.884, + -755.769, + -658.653, + -561.538, + -464.422, + -367.306, + -270.191, + -173.075, + -120, + -82, + -52, + -22, + 8, + 19, + 80, + 120, + 200, + 300, + 400, + 500, + 600, + 700, + 800, + 900, + 1000, + 1100, + 1200, + 1300, + 1400, + 1500, + 1640, + 1807.5, + 1975.01, + 2142.51, + 2300, + 2467.5, + 2635.01, + 2802.51, + 2970.02, + 3000, + ] + ) + + # Define the corresponding density values for each bin + values = numpy.array( + [ + 0.00121, + 0.102695, + 0.202695, + 0.302695, + 0.402695, + 0.502695, + 0.602695, + 0.702695, + 0.802695, + 0.880021, + 0.926911, + 0.957382, + 0.984277, + 1.01117, + 1.02955, + 1.0616, + 1.1199, + 1.11115, + 1.16447, + 1.22371, + 1.28295, + 1.34219, + 1.40142, + 1.46066, + 1.5199, + 1.57914, + 1.63838, + 1.69762, + 1.75686, + 1.8161, + 1.87534, + 1.94643, + 2.03808, + 2.13808, + 2.23808, + 2.33509, + 2.4321, + 2.5321, + 2.6321, + 2.7321, + 2.79105, + 2.9, + ] + ) + + # Clip the HU array values to be within the range of defined bins + hu_clipped = numpy.clip(hu, bins[0], bins[-1]) + + # Apply Median filter to remove a bit of remaining noise + hu_clipped = median_filter(hu_clipped, size=2) + + # Find the corresponding bin for each HU value + bin_indices = numpy.digitize(hu_clipped, bins, right=True) + + # Map each bin index to the corresponding density value + rho = values[bin_indices - 1] + + return rho + + +def calculate_time_difference( + date_str1: str, date_str2: str, date_format: str = "%Y%m%d %H%M%S" +) -> float: + """Calculate the time difference in hours between two dates. + + This function computes the time difference between two dates provided as strings. + The dates should be in the format specified by date_format. + + Parameters + ---------- + date_str1 : str + First date string. + date_str2 : str + Second date string. + date_format : str, optional + Format string for parsing the dates, by default "%Y%m%d %H%M%S". + + Returns + ------- + float + Time difference in hours. + + Notes + ----- + - The function removes any fractional seconds from the input strings. + - The time difference is calculated as (date_str1 - date_str2). + - The result is returned in hours as a float value. + """ + # Remove fractional seconds if present + # Clean up: + date_str1 = date_str1.split(".")[0] + date_str2 = date_str2.split(".")[0] + + # Convert string dates to datetime objects + datetime1 = datetime.strptime(date_str1, date_format) + datetime2 = datetime.strptime(date_str2, date_format) + + # Calculate the difference in hours + time_diff = datetime1 - datetime2 + hours_diff = time_diff.total_seconds() / 3600 + + return hours_diff + + +# New functions to extract parameters from JSON. +def extract_exponential_params_from_json( + json_data: dict, cycle: str, region: str +) -> Tuple[Dict[str, float], bool, Dict[str, float]]: + """Extract parameters of fit for a defined region and cycle from a JSON dictionary of a patient. + + Parameters + ---------- + json_data : dict + The patient's JSON dictionary. + cycle : str + The cycle ID. + region : str + The region of interest. + + Returns + ------- + Tuple[Dict[str, float], bool, Dict[str, float]] + A Tuple consisting of the exponential parameters from previous fit, + a boolean representing whether or not initial uptake was accounted for in previous fit, + and all the parameters of the fit. + """ + with_uptake = False + # Determine order: + parameters = json_data[cycle][0]["VOIs"][region]["fit_params"] + washout_ratio = json_data[cycle][0]["VOIs"][region]["washout_ratio"] + + # Handle Legacy: + if len(parameters) in [3, 5]: + return extract_exponential_params_from_json_legacy(json_data, cycle, region) + + fit_order = len(parameters) // 2 + + exponential_idxs = [1, 3, 5] + param_name_base = ["A", "B", "C"] + + fixed_parameters: Dict[str, float] = {} + all_parameters: Dict[str, float] = {} + for order in range(fit_order): + fixed_parameters[f"{param_name_base[order]}2"] = parameters[ + exponential_idxs[order] + ] + + all_parameters[f"{param_name_base[order]}1"] = parameters[ + exponential_idxs[order] - 1 + ] + all_parameters[f"{param_name_base[order]}2"] = parameters[ + exponential_idxs[order] + ] + if washout_ratio is not None: + # Fix A1 ONLY + if "B1" in all_parameters: + fixed_parameters["B1"] = all_parameters["B1"] + if fit_order == 2 and parameters[0] == -parameters[2]: + with_uptake = True + + if fit_order == 3 and parameters[4] == -(parameters[0] + parameters[2]): + with_uptake = True + return fixed_parameters, with_uptake, all_parameters, washout_ratio + + +def extract_exponential_params_from_json_legacy( + json_data: dict, cycle: str, region: str +) -> Tuple[Dict[str, float], bool, Dict[str, float]]: + """Extract parameters of fit for a defined region and cycle from a patient JSON dictionary. + + Legacy function to support the previous version of patient JSON where not all parameters + of fit were stored. + + Parameters + ---------- + json_data : dict + The patient's JSON dictionary. + cycle : str + The cycle ID + region : str + The region of interest + + Returns + ------- + Tuple[Dict[str, float], bool, Dict[str, float]] + A Tuple consisting of the exponential parameters from previous fit, + a boolean representing whether or not initial uptake was accounted for in previous fit, + and all the parameters of the fit. + + Raises + ------ + AssertionError + When the parameter configuration is incompatible with new format. + """ + # Read Parameters: + parameters = json_data[cycle][0]["VOIs"][region]["fit_params"] + + if len(parameters) not in [3, 5]: + raise AssertionError( + "Legacy parameter extraction from JSON not compatible with this JSON file." + ) + + if len(parameters) == 3: + # Order = 2, with uptake: + return ( + {"A2": parameters[1], "B2": parameters[2]}, + True, + { + "A1": parameters[0], + "A2": parameters[1], + "B1": -parameters[0], + "B2": parameters[2], + }, + ) + + else: + # Order = 3, with uptake: + return ( + {"A2": parameters[1], "B2": parameters[3], "C2": parameters[4]}, + True, + { + "A1": parameters[0], + "A2": parameters[1], + "B1": parameters[2], + "B2": parameters[3], + "C1": -(parameters[0] + parameters[2]), + "C2": parameters[4], + }, + ) + + +def initialize_biokinetics_from_prior_cycle( + config: dict, prior_treatment_data: dict, cycle: str +) -> dict: + """Initialize biokinetics parameters from a previous treatment cycle. + + Parameters + ---------- + config : dict + Configuration dictionary. + prior_treatment_data : dict + Prior treatment data dictionary. + cycle : str + Cycle identifier. + + Returns + ------- + dict + Updated configuration dictionary. + """ + for roi, roi_info in config["VOIs"].items(): + + if ( + "biokinectics_from_previous_cycle" in roi_info + and roi_info["biokinectics_from_previous_cycle"] + ): + + # --- original behavior --- + if roi in prior_treatment_data[cycle][0]["VOIs"]: + + fixed_param, with_uptake, all_params, washout_ratio = ( + extract_exponential_params_from_json( + json_data=prior_treatment_data, + cycle=cycle, + region=roi, + ) + ) + + fit_order = len(all_params) // 2 + + # --- NEW fallback for new lesions --- + else: + fixed_params_list = [] + with_uptake = False + washout_ratio = None + + for voi_name, voi_data in prior_treatment_data[cycle][0][ + "VOIs" + ].items(): + if "Lesion" in voi_name and voi_data.get("fitting_eq") == 1: + + fixed_i, with_uptake_i, all_i, washout_ratio_i = ( + extract_exponential_params_from_json( + json_data=prior_treatment_data, + cycle=cycle, + region=voi_name, + ) + ) + + fixed_params_list.append(fixed_i) + with_uptake = with_uptake or with_uptake_i + washout_ratio = washout_ratio_i + + if len(fixed_params_list) == 0: + raise ValueError( + f"No lesions with fit_order == 1 found in {cycle} " + f"to initialize {roi}" + ) + + # mean of dictionaries (same keys guaranteed) + fixed_param = { + key: sum(d[key] for d in fixed_params_list) / len(fixed_params_list) + for key in fixed_params_list[0] + } + + fit_order = 1 + all_params = fixed_param + + # --- unchanged --- + config["VOIs"][roi] = { + "fixed_parameters": fixed_param, + "fit_order": fit_order, + "param_init": all_params, + "with_uptake": with_uptake, + "washout_ratio": washout_ratio, + } + + print( + f"{roi} will utilize the following parameters from the previous cycle {cycle}:" + ) + print(fixed_param) + print("") + + return config + + +def plot_MIP(SPECT=None, vmax=300000, figsize=(10, 5), ax=None): + """Plot Maximum Intensity Projection (MIP) of SPECT data. + + Parameters + ---------- + ax : matplotlib.axes.Axes, optional + Axes object to plot on. If None, a new figure and axes will be created. + SPECT : numpy.ndarray + SPECT data array. + vmax : int, optional + Maximum value for display, by default 300000. + figsize : tuple, optional + Figure size (width, height) in inches, by default (10, 5). + Only used if ax is None. + + Returns + ------- + fig : matplotlib.figure.Figure + Figure object. + ax : matplotlib.axes.Axes + Axes object with the plot. + """ + if ax is None: + fig, ax = plt.subplots(figsize=figsize) + else: + fig = ax.get_figure() + + plt.sca(ax) + plt.imshow( + SPECT.max(axis=0).T, cmap="Greys", interpolation="Gaussian", vmax=vmax, vmin=0 + ) + plt.xlim(30, 100) + plt.ylim(0, 234) + + plt.axis("off") + + plt.xticks([]) + plt.yticks([]) + + return fig, ax + + +def plot_MIP_with_mask_outlines(ax, SPECT, masks=None, vmax=300000, label=None): + """Plot Maximum Intensity Projection (MIP) of SPECT data with masks outlines. + + Parameters + ---------- + ax : _type_ + _description_ + SPECT : _type_ + _description_ + masks : _type_, optional + _description_, by default None + vmax : int, optional + _description_, by default 300000 + """ + plt.sca(ax) + spect_mip = SPECT.max(axis=0) + plt.imshow(spect_mip.T, cmap="Greys", interpolation="Gaussian", vmax=vmax, vmin=0) + + if masks is not None: + for organ, mask in masks.items(): + organ_lower = organ.lower() + if "peak" in organ_lower: + continue + else: + if "kidney" in organ_lower: + color = "lime" + elif "parotid" in organ_lower: + color = "red" + elif "submandibular" in organ_lower: + color = "red" + elif "lesion" in organ_lower: + color = "m" + else: + continue + + mip_mask = mask.max(axis=0) + if mip_mask.shape != spect_mip.shape: + mip_mask = mip_mask.T + + plt.contour( + numpy.transpose(mip_mask, (1, 0)), + levels=[0.5], + colors=[color], + linewidths=1.5, + alpha=0.5, + ) + + # --- Add label at mask center --- + if label is True: + ys, xs = numpy.where(mip_mask > 0) + + if len(xs) > 0: + # Corrected for transpose in contour + x_center = ys.mean() - 0.2 * ys.mean() # was xs + y_center = xs.mean() # was ys + + plt.text( + x_center, + y_center, + organ, + color=color, + fontsize=8, + ha="center", + va="center", + alpha=0.7, + ) + + plt.xlim(15, 105) + plt.ylim(0, 234) + plt.axis("off") + plt.xticks([]) + plt.yticks([]) diff --git a/pytheranostics/plots/__init__.py b/pytheranostics/plots/__init__.py new file mode 100644 index 0000000..3731650 --- /dev/null +++ b/pytheranostics/plots/__init__.py @@ -0,0 +1 @@ +"""PyTheranostics package.""" diff --git a/pytheranostics/plots/plots.py b/pytheranostics/plots/plots.py new file mode 100644 index 0000000..eaa6a24 --- /dev/null +++ b/pytheranostics/plots/plots.py @@ -0,0 +1,149 @@ +"""Plotting utilities for PyTheranostics workflows.""" + +from pathlib import Path +from typing import Optional + +import lmfit +import matplotlib.pyplot as plt +import numpy + + +def ewin_montage(img: numpy.ndarray, ewin: dict) -> None: + """Create a montage of energy window images. + + This function creates a 2x6 subplot montage showing energy window images + from two detectors. The top row shows images from Detector 1, and the + bottom row shows corresponding images from Detector 2. + + Parameters + ---------- + img : numpy.ndarray + Image data array containing energy window images. + Shape should be (2*N, height, width) where N is the number of energy windows. + ewin : dict + Dictionary containing energy window information. + Keys should be window identifiers, and values should be dictionaries + containing at least a 'center' key with the energy value in keV. + + Notes + ----- + - The function creates a figure with size (22, 6). + - Each energy window is displayed in a separate subplot. + - Colorbars are added to each subplot. + - The layout is automatically adjusted using tight_layout(). + """ + plt.figure(figsize=(22, 6)) + for ind, i in enumerate(range(0, int(img.shape[0]), 2)): + keys = list(ewin.keys()) + + # Top row Detector 1 + plt.subplot(2, 6, ind + 1) + plt.imshow(img[i, :, :]) + plt.title(f'Detector1 {ewin[keys[ind]]["center"]} keV') + plt.colorbar() + + # Bottom row Detector 2 + plt.subplot(2, 6, ind + 7) + plt.imshow(img[i + 1, :, :]) + plt.title(f'Detector2 {ewin[keys[ind]]["center"]} keV') + plt.colorbar() + + plt.tight_layout() + + +def plot_tac_residuals( + result: lmfit.model.ModelResult, + region: str, + cycle: int, + x_label: str = "Time [hr]", + y_label: str = "Activity [MBq]", + output_dir: Optional[Path] = None, +) -> None: + """Plot time-activity curve and residuals.""" + # Create a figure with 3 subplots + # Create a figure with 3 subplots + _, axs = plt.subplots(1, 3, figsize=(12, 4), constrained_layout=True) + + # Extract fitted parameters and format them + params = result.params.valuesdict() + formatted_params = {key: f"{value:.3f}" for key, value in params.items()} + + # Construct the mathematical expression for the title + num_exponentials = len(params) // 2 # Each exponential has two parameters + terms = [] + for _, term in enumerate(["A", "B", "C"][:num_exponentials]): + A1 = formatted_params.get(f"{term}1", "0") + A2 = formatted_params.get(f"{term}2", "0") + terms.append(f"${A1}e^{{-{A2} t}}$") + function_expression = " + ".join(terms) + title_text = "$A(t) = $" + f"{function_expression}" + + # Retrieve x_data and y_data from the fit result + x_data = result.userkws["x"] + y_data = result.data + if result.weights is not None: + weights = 1 / result.weights + else: + weights = None + # Generate x-values for plotting the fitted model starting from x=0 + x_fit = numpy.linspace(0, x_data.max() * 3, 500) + y_fit = result.eval(x=x_fit) + + # First subplot: Linear scale plot + ax1 = axs[0] + # Plot data points + ax1.errorbar(x_data, y_data, yerr=weights, fmt="o", markersize=5) + # Plot fitted model + ax1.plot(x_fit, y_fit, color="red") + ax1.set_xlim(left=0) # Start x-axis from zero + ax1.set_xlim(right=x_data.max() * 2) # Start y-axis from zero + ax1.set_ylim(bottom=0) # Start y-axis from zero + ax1.set_title(region) + ax1.set_xlabel(x_label) + ax1.set_ylabel(y_label) + # Add R-squared and AIC as text + try: + ax1.text(0.7, 0.9, f"$R^2={result.rsquared:.3f}$", transform=ax1.transAxes) + ax1.text(0.7, 0.85, f"AIC={result.aic:.3f}", transform=ax1.transAxes) + except AttributeError: + pass + # Remove legend if present + legend = ax1.get_legend() + if legend: + legend.remove() + + # Second subplot: Semilog plot + ax2 = axs[1] + # Plot data points + ax2.plot(x_data, y_data, "o", markersize=5) + # Plot fitted model + ax2.plot(x_fit, y_fit, color="red") + ax2.set_xlim(left=0) # Start x-axis from zero + ax2.set_xlim(right=x_data.max() * 2) # Start y-axis from zero + ax2.set_yscale("log") + ax2.set_title(title_text) + ax2.set_xlabel(x_label) + ax2.set_ylabel(y_label) + # Remove legend if present + legend = ax2.get_legend() + if legend: + legend.remove() + + # Third subplot: Residuals plot + ax3 = axs[2] + result.plot_residuals(ax=ax3, data_kws={"markersize": 5}) + ax3.set_title("Residuals") + ax3.set_xlabel(x_label) + ax3.set_ylabel("Residuals") + + if output_dir is not None: + plt.savefig( + output_dir / f"{region}_fit_Cycle_0{cycle}.png", + format="png", + bbox_inches="tight", + dpi=300, + ) + + plt.show() + + return None diff --git a/pytheranostics/preclinical_dosimetry/biodose.py b/pytheranostics/preclinical_dosimetry/biodose.py new file mode 100644 index 0000000..1b93d57 --- /dev/null +++ b/pytheranostics/preclinical_dosimetry/biodose.py @@ -0,0 +1,1226 @@ +"""Preclinical biodistribution analysis helpers for BioDose workflows.""" + +import datetime +from copy import deepcopy +from os import makedirs, path + +import numpy as np +import pandas as pd +from numpy import exp, log +from scipy import integrate + +from pytheranostics.fits.fits import ( + biexp_fun, + biexp_fun_uptake, + exponential_fit_lmfit, + monoexp_fun, +) +from pytheranostics.plots.plots import plot_tac_residuals +from pytheranostics.shared.resources import resource_path + + +def _data_resource(relative_path: str): + return resource_path("pytheranostics.data", relative_path) + + +class BioDose: + """Analyze biodistribution data gathered from preclinical experiments.""" + + def __init__( + self, isotope, half_life, phantom, mouse_mass, sex, uptake=None, timepoints=None + ): + """Initialize the biodistribution analysis (half_life expressed in hours).""" + if uptake is None: + uptake = [] + if timepoints is None: + timepoints = [] + + self.isotope = isotope + self.half_life = half_life + self.phantom = phantom + self.mouse_mass = mouse_mass + self.uptake = uptake + self.sex = sex + self.t = timepoints + self.biodi = None + self.wb_m = int(self.mouse_mass[:-1]) + + def read_biodi(self, biodi_file): + """Read a biodistribution CSV and populate ``self.biodi``.""" + print( + "Reading biodistribution information from the file: {}".format(biodi_file) + ) + biodi = pd.read_csv(biodi_file) + + # Generalize the biodi organ names + biodi["Organ"] = biodi["Organ"].str.title() + biodi["Organ"] = biodi["Organ"].replace("Adrenal Glands", "Adrenals") + biodi["Organ"] = biodi["Organ"].replace("Adrenal", "Adrenals") + biodi["Organ"] = biodi["Organ"].replace("Gall Bladder", "Gallbladder") + biodi["Organ"] = biodi["Organ"].replace("Bone Marrow", "Red Marrow") + biodi["Organ"] = biodi["Organ"].replace("Muscles", "Muscle") + biodi["Organ"] = biodi["Organ"].replace("Urine", "Bladder") + biodi["Organ"] = biodi["Organ"].replace( + "Submandibular Glands", "Salivary Glands" + ) + biodi["Organ"] = biodi["Organ"].replace( + "Submandibular Gland", "Salivary Glands" + ) + biodi["Organ"] = biodi["Organ"].replace("Seminal Glands", "Seminals") + biodi["Organ"] = biodi["Organ"].replace("Seminal", "Seminals") + biodi["Organ"] = biodi["Organ"].replace("Seminal Vesicles", "Seminals") + biodi["Organ"] = biodi["Organ"].replace("Testis", "Testes") + biodi["Organ"] = biodi["Organ"].replace("Lung", "Lungs") + biodi["Organ"] = biodi["Organ"].replace("Large Intestines", "Large Intestine") + biodi["Organ"] = biodi["Organ"].replace( + "Small Intestines", + ) + biodi["Organ"] = biodi["Organ"].replace("Bone", "Skeleton") + biodi["Organ"] = biodi["Organ"].replace("Kidney", "Kidneys") + biodi["Organ"] = biodi["Organ"].replace("Tumour", "Tumor") + + biodi.set_index("Organ", inplace=True) + biodi = pd.concat( + [biodi.iloc[:, : len(self.t)], biodi.iloc[:, len(self.t) :]], + axis=1, + keys=["%ID/g", "sigma"], + ) + biodi.sort_index(inplace=True) + + self.raw_biodi = biodi + self.biodi = biodi.copy() + print("Raw biodi (all data at injection time) available in self.raw_biodi") + + decay_factor = np.round(exp(-log(2) / self.half_life * self.t), 5) + + print("Decay factors for this isotope at given times are\n", decay_factor) + self.biodi["%ID/g"] = np.round(biodi["%ID/g"] * decay_factor, 5) + self.biodi["sigma"] = np.round(biodi["sigma"] * decay_factor, 5) + print("Decayed biodistribution stored in self.biodi") + + def initialize_results_df(self): + """Initialize the results DataFrame with the expected columns.""" + columns = [ + "Mono-Exponential", + "Bi-Exponential", + "Bi-Exponential_uptake", + "Tri-Exponential", + "Perctage_diff - mono vs bi washout", + "Perctage_diff - bi vs tri uptake washout", + ] + if not hasattr(self, "area") or self.area is None: + self.area = pd.DataFrame(index=self.biodi.index, columns=columns) + if not hasattr(self, "fit_results") or self.fit_results is None: + self.fit_results = {} + + def update_fit_results( + self, + org, + mono_params=None, + bi_params=None, + uptake_params=None, + area_mono=None, + area_bi=None, + area_uptake=None, + ): + """Update the stored fit parameters and AUC metrics for one organ.""" + area_mono_val = ( + area_mono[0] if isinstance(area_mono, (tuple, list)) else area_mono + ) + area_bi_val = area_bi[0] if isinstance(area_bi, (tuple, list)) else area_bi + + if mono_params and area_mono: + self.area.loc[org, "Mono-Exponential"] = area_mono_val + + if bi_params and area_bi: + self.area.loc[org, "Bi-Exponential"] = area_bi_val + if area_mono and area_mono_val != 0: + self.area.loc[org, "Perctage_diff - mono vs bi washout"] = ( + abs(area_mono_val - area_bi_val) / area_mono_val * 100 + ) + + if uptake_params and area_uptake: + self.area.loc[org, "Bi-Exponential_uptake"] = area_uptake[0] + + self.fit_results[org] = [ + (mono_params["A1"], mono_params["A2"]) if mono_params else None, + area_mono, + ( + (bi_params["A1"], bi_params["A2"], bi_params["B1"], bi_params["B2"]) + if bi_params + else ( + ( + uptake_params["A1"], + uptake_params["A2"], + uptake_params["B1"], + uptake_params["B2"], + ) + if uptake_params + else None + ) + ), + area_bi if bi_params else area_uptake, + ] + + def curve_fits( + self, + organlist=None, + uptake=False, + maxev=100000, + monoguess=(1, 0.1), + uptakeguess=(1, 1, -1, 1), + ignore_weights=False, + append_zero=True, + tps_to_skip_fit=0, + ): + """Fit exponential models (with optional uptake) using lmfit.""" + decayconst = log(2) / self.half_life + + if organlist is None: + organlist = self.biodi.index + + # Initialize results DataFrame + self.initialize_results_df() + + dfs = [] + for org in organlist: + bio_data = self.biodi.loc[org]["%ID/g"] + activity = np.asarray(bio_data) + t = np.asarray(self.t) + sigmas = np.asarray(self.biodi.loc[org]["sigma"]) + ylabel = "%ID/g" + + if not uptake: + # Mono-exponential fit + result_mono, fitted_mono = exponential_fit_lmfit( + t[tps_to_skip_fit:], + activity[tps_to_skip_fit:], + num_exponentials=1, + sigma=sigmas[tps_to_skip_fit:], + params_init={"A1": monoguess[0], "A2": monoguess[1]}, + bounds={"A1": (0, None), "A2": (decayconst, None)}, + ) + plot_tac_residuals(result=result_mono, region=org, y_label=ylabel) + + # Bi-exponential fit + result_bi, fitted_bi = exponential_fit_lmfit( + t[tps_to_skip_fit:], + activity[tps_to_skip_fit:], + num_exponentials=2, + sigma=sigmas[tps_to_skip_fit:], + params_init={ + "A1": result_mono.params["A1"].value * 0.6, + "A2": result_mono.params["A2"].value * 0.8, + "B1": result_mono.params["A1"].value * 0.4, + "B2": result_mono.params["A2"].value * 1.2, + }, + bounds={ + "A1": (0, None), + "A2": (decayconst, None), + "B1": (0, None), + "B2": (decayconst, None), + }, + ) + plot_tac_residuals(result=result_bi, region=org, y_label=ylabel) + # Store results + mono_params = result_mono.params.valuesdict() + bi_params = result_bi.params.valuesdict() + + organ_data = { + "Organ": [org], + "mono_exp:%ID/g": [mono_params["A1"]], + "mono_exp:lambda_effective_1/h": [mono_params["A2"]], + "bi_exp:1_%ID/g": [bi_params["A1"]], + "bi_exp:lambda_effective1_1/h": [bi_params["A2"]], + "bi_exp:lambda_effective2_1/h": [bi_params["B2"]], + "bi_exp:2_%ID/g": [bi_params["B1"]], + } + organ_df = pd.DataFrame(organ_data, index=[org]) + dfs.append(organ_df) + + # Calculate areas + if tps_to_skip_fit == 0: + area_mono = integrate.quad( + lambda x: monoexp_fun(x, mono_params["A1"], mono_params["A2"]), + 0, + np.inf, + ) + area_bi = integrate.quad( + lambda x: biexp_fun( + x, + bi_params["A1"], + bi_params["A2"], + bi_params["B1"], + bi_params["B2"], + ), + 0, + np.inf, + ) + else: + # Handle skipped points by adding trapezoidal areas + triangle_area = t[0] * bio_data.iloc[0] / 2 + trapezoid_area = ( + (bio_data.iloc[0] + bio_data.iloc[1]) * (t[1] - t[0]) / 2 + ) + + monoexp_area = integrate.quad( + lambda x: monoexp_fun(x, mono_params["A1"], mono_params["A2"]), + t[tps_to_skip_fit], + np.inf, + ) + biexp_area = integrate.quad( + lambda x: biexp_fun( + x, + bi_params["A1"], + bi_params["A2"], + bi_params["B1"], + bi_params["B2"], + ), + t[tps_to_skip_fit], + np.inf, + ) + + if tps_to_skip_fit == 1: + area_mono = triangle_area + trapezoid_area + monoexp_area[0] + area_bi = triangle_area + trapezoid_area + biexp_area[0] + elif tps_to_skip_fit >= 2: + trapezoid_area2 = ( + (bio_data.iloc[1] + bio_data.iloc[2]) * (t[2] - t[1]) / 2 + ) + area_mono = ( + triangle_area + + trapezoid_area + + trapezoid_area2 + + monoexp_area[0] + ) + area_bi = ( + triangle_area + + trapezoid_area + + trapezoid_area2 + + biexp_area[0] + ) + + self.update_fit_results( + org, + mono_params=mono_params, + bi_params=bi_params, + area_mono=area_mono, + area_bi=area_bi, + ) + + else: + # Uptake model + result_uptake, fitted_uptake = exponential_fit_lmfit( + t[tps_to_skip_fit:], + activity[tps_to_skip_fit:], + num_exponentials=2, + sigma=sigmas[tps_to_skip_fit:], + with_uptake=True, + params_init={ + "A1": uptakeguess[0], + "A2": uptakeguess[1], + "B1": uptakeguess[2], + "B2": uptakeguess[3], + }, + bounds={ + "A1": (0, None), + "A2": (decayconst, None), + "B1": (None, None), + "B2": (decayconst, None), + }, + ) + plot_tac_residuals(result=result_uptake, region=org, y_label=ylabel) + uptake_params = result_uptake.params.valuesdict() + area_uptake = integrate.quad( + lambda x: biexp_fun_uptake( + x, uptake_params["A1"], uptake_params["A2"], uptake_params["B2"] + ), + 0, + np.inf, + ) + + self.update_fit_results( + org, uptake_params=uptake_params, area_uptake=area_uptake + ) + + try: + self.fitting_parameters = pd.concat(dfs, ignore_index=True) + except Exception as e: + print(f"Error creating fitting parameters DataFrame: {e}") + + def num_decays(self, fit_accepted): + """Compute organ decays based on the accepted fit type.""" + self.disintegrations = pd.DataFrame(index=self.biodi.index, columns=["%ID/g*h"]) + self.fit_accepted = fit_accepted + + for organ, choice in self.fit_accepted.items(): + if choice == 1: + column_name = "Mono-Exponential" + elif choice == 2: + column_name = "Bi-Exponential" + elif choice == 3: + column_name = "Bi-Exponential_uptake" + elif choice == 4: + column_name = "Tri-Exponential" + else: + continue # Skip choices that are not 1, 2, 3, or 4 + + if organ in self.area.index and column_name in self.area.columns: + area_value = self.area.loc[organ, column_name] + self.disintegrations.at[organ, "%ID/g*h"] = area_value + + if "Right Colon" in self.disintegrations.index: + if pd.isna(self.disintegrations.loc["Right Colon"]).any(): + self.disintegrations.loc["Right Colon"] = self.disintegrations.loc[ + "Left Colon" + ] + self.disintegrations.loc["Rectum"] = self.disintegrations.loc[ + "Left Colon" + ] + + if "Red Marrow" in self.disintegrations.index: + if pd.isna(self.disintegrations.loc["Red Marrow"]).any(): + self.disintegrations.loc["Red Marrow"] = ( + self.disintegrations.loc["Heart Contents"] * 0.34 + ) + + self.disintegrations.loc["Remainder Body"] = np.nan + + def calculate_tumor_sink_effect(self): + """Compute the tumor sink effect factor for downstream corrections.""" + tumor_value = self.disintegrations["h"]["Tumor"] + + wb_value = self.disintegrations["h"].sum() + + self.tumor_sink_effect_factor = 1 + ( + tumor_value / (wb_value - tumor_value) + ) # represents a multiplicative adjustment to the organ's disintegration value to account for normalizing or redistributing activity after subtracting the tumor's share from the whole body + print(f"Tumor sink effect: {self.tumor_sink_effect_factor}") + + def tumor_sink_effect_correction(self, df): + """Apply the tumor sink effect factor to a biodistribution DataFrame.""" + df_corrected = df.copy() + + for organ in df_corrected.columns: + if organ != "Tumor": + df_corrected[organ] *= self.tumor_sink_effect_factor + + return df_corrected + + def phantom_data(self): + """Load the reference phantom masses and reconcile them with biodistribution organs.""" + if "mouse" in self.phantom.lower(): + with _data_resource( + "phantom/mouse/mouse_phantom_masses.csv" + ) as phantom_path: + self.phantom_mass = pd.read_csv(phantom_path) + # elif 'human' in self.phantom.lower(): + # self.phantom_mass = pd.read_csv(path.join(PHANTOM_PATH,'human_phantom_masses.csv')) + self.phantom_mass.set_index("Organ", inplace=True) + self.phantom_mass.sort_index(inplace=True) + self.not_inphantom = [] + for org in self.biodi.index: # for org in self.disinteggrations.index: + if org not in self.phantom_mass.index: + self.not_inphantom.append(org) + rob = ["Remainder Body"] + self.not_inphantom = list(set(self.not_inphantom) - set(rob)) + print( + "These organs from the biodi are not modelled in the phantom\n{}".format( + self.not_inphantom + ) + ) + + self.phantom_mass.loc["Remainder Body"] = ( + self.phantom_mass.loc["Body"] + - self.phantom_mass.loc[self.phantom_mass.index != "Body"].sum() + ) + largeintestine = ["Left Colon", "Right Colon", "Rectum"] + self.not_inbiodi = [] + for org in self.phantom_mass.index: + if ( + org not in self.disintegrations.index + and org != "Body" + and org != "Remainder Body" + ): + self.not_inbiodi.append(org) + self.not_inbiodi = list(set(self.not_inbiodi) - set(largeintestine)) + print( + "\nThese organs modelled in the phantom were not included in the biodistribution.\n{}".format( + self.not_inbiodi + ) + ) + self.phantom_mass.loc[self.not_inbiodi].sum() + self.phantom_mass.loc["Remainder Body"] = ( + self.phantom_mass.loc["Remainder Body"] + + self.phantom_mass.loc[self.not_inbiodi].sum() + ) + + def remainder_body_uptake(self, tumor_name=None): + """Redistribute activity from organs that are not modeled in the phantom.""" + print("At this point we are ignoring the tumor") + if tumor_name: + self.not_inphantom_notumor = [ + org for org in self.not_inphantom if tumor_name not in org + ] + # tumortemp = self.biodi.loc[tumor_name] # TODO: Use this variable or remove (flake8) + else: + self.not_inphantom_notumor = self.not_inphantom + print(self.not_inphantom_notumor) + print("Disintegrations\n") + + # These organs that are not modelled in the phantom are now going to be scaled using mass information from the literature: + if "mouse" in self.phantom.lower(): + with _data_resource( + "phantom/mouse/mouse_notinphantom_masses.csv" + ) as lit_path: + self.literature_mass = pd.read_csv(lit_path) + + elif "human" in self.phantom.lower(): + with _data_resource( + "phantom/human/human_notinphantom_masses.csv" + ) as lit_path: + self.literature_mass = pd.read_csv(lit_path) + + print(self.phantom.lower()) + print(self.literature_mass) + + self.literature_mass.set_index("Organ", inplace=True) + + if "mouse" in self.phantom.lower(): + self.literature_mass.loc["Muscle"] = ( + self.phantom_mass.loc["Remainder Body"] - self.literature_mass.sum() + ) + + self.literature_mass = self.literature_mass.loc[ + self.disintegrations.index.intersection(self.literature_mass.index) + ] + print("\nLiterature Mass (g)\n") + print(self.literature_mass) + print(self.phantom_mass) + try: + self.not_inphantom_notumor.remove("Tail") + except ValueError: + pass + + # Residual is the remaining carcass of the mouse after removing the organs; not all biodi study measure its activity, but some does + if "Residual" in self.disintegrations.index: + self.phantom_mass.loc["Residual"] = ( + self.phantom_mass.loc["Remainder Body"] - self.literature_mass.sum() + ) + else: + pass + + if "mouse" in self.phantom.lower(): + self.disintegrations["%ID*h"] = ( + self.disintegrations["%ID/g*h"] * self.phantom_mass[self.mouse_mass] + ) + if "Residual" in self.disintegrations.index: + self.not_inphantom_notumor.remove("Residual") + self.disintegrations.loc["Remainder Body", "%ID*h"] = ( + self.disintegrations["%ID/g*h"] + .loc[self.not_inphantom_notumor] + .mul( + self.literature_mass[self.mouse_mass].loc[ + self.not_inphantom_notumor + ] + ) + .sum() + ) + ( + self.disintegrations.loc["Residual", "%ID/g*h"] + * (self.phantom_mass.loc["Residual", self.mouse_mass]) + ) + else: + self.disintegrations.loc["Remainder Body", "%ID*h"] = ( + self.disintegrations["%ID/g*h"] + .loc[self.not_inphantom_notumor] + .mul( + self.literature_mass[self.mouse_mass].loc[ + self.not_inphantom_notumor + ] + ) + .sum() + ) + for org in self.not_inphantom_notumor: + if org != "Tail": + self.disintegrations.loc[org, "%ID*h"] = ( + self.disintegrations.loc[org, "%ID/g*h"] + * self.literature_mass.loc[org, self.mouse_mass] + ) + else: + pass + + elif "human" in self.phantom.lower(): + print("x") + self.disintegrations["%ID*h Female"] = ( + self.disintegrations["%ID/g*h"] * self.phantom_mass["Female"] + ) + self.disintegrations["%ID*h Male"] = ( + self.disintegrations["%ID/g*h"] * self.phantom_mass["Male"] + ) + + self.disintegrations.loc["Remainder Body", "%ID*h Female"] = ( + self.disintegrations["%ID/g*h"] + .loc[self.not_inphantom_notumor] + .mul(self.literature_mass["Female"]) + .sum() + ) + self.disintegrations.loc["Remainder Body", "%ID*h Male"] = ( + self.disintegrations["%ID/g*h"] + .loc[self.not_inphantom_notumor] + .mul(self.literature_mass["Male"]) + .sum() + ) + + self.disintegrations["h"] = self.disintegrations["%ID*h"] / 100 + self.disintegrations_all_organs = ( + self.disintegrations.copy() + ) # Store the original disintegrations before dropping organs + self.disintegrations.drop( + self.not_inphantom_notumor, inplace=True + ) # Only organs that are in the phantom will be kept in the disintegrations dataframe and passed to olinda + + def not_inphantom_notumor_fun(self): + """Drop leftover organs that the phantom model does not include.""" + self.disintegrations.drop(self.not_inphantom_notumor, inplace=True) + + def add_tumor_mass(self, tumor_name, tumor_mass): + """Register a tumor entry with the provided mass (grams).""" + self.phantom_mass.loc[tumor_name] = tumor_mass + + def calculate_absorbed_dose(self, model, disintegrations): + """ + Calculate absorbed dose per target organ based on the selected model. + + Parameters + ---------- + model : str + One of ``mouse25g``, ``mouse30g``, ``mouse35g``, ``Female``, or ``Male``. + disintegrations : pandas.DataFrame + Source-organ disintegrations expressed in hours. + + Returns + ------- + pandas.DataFrame + Absorbed dose in mGy and Gy for each target organ. + """ + disintegrations = disintegrations.copy() + + model_files = { + "mouse25g": "177Lu_mouse_25g_sfactors_olinda.csv", + "mouse30g": "177Lu_mouse_30g_sfactors_olinda.csv", + "mouse35g": "177Lu_mouse_35g_sfactors_olinda.csv", + "Female": "177Lu_S_values_female_olinda.csv", + "Male": "177Lu_S_values_male_olinda.csv", + } + print(model) + if model not in model_files: + raise ValueError( + f"Invalid model '{model}'. Expected one of: {list(model_files.keys())}" + ) + + with _data_resource(f"s-values/{model_files[model]}") as svalues_path: + svalues_df = pd.read_csv(svalues_path, index_col=0) # S-values in mSv/MBq-s + print(f"Loaded S-values from: {svalues_path}") + print("Target organs (S-value index):", svalues_df.index.tolist()) + + organ_name_map_mouse_olinda = { + "Large Intestine": "Large Int", + "Small Intestine": "Small Int", + "Bladder": "Urin Blad", + "Remainder Body": "Tot Body", + } + organ_name_map_human_olinda = { + "Gallbladder": "GB Cont", + "Left Colon": "LLI Cont", + "Right Colon": "ULI Cont", + "Stomach": "StomCont", + "Salivary Glands": "Salivary", + "Red Marrow": "Red Mar.", + "Bone Surfaces": "CortBone", + "Heart": "Hrt Wall", + "Heart Contents": "HeartCon", + "Small Intestine": "SI Cont", + "Bladder": "UB Cont", + "Remainder Body": "Tot Body", + } + + if "mouse" in model: + disintegrations.index = disintegrations.index.to_series().replace( + organ_name_map_mouse_olinda + ) + else: + disintegrations.index = disintegrations.index.to_series().replace( + organ_name_map_human_olinda + ) + + print(disintegrations) + if "mouse" in model: + disintegrations["s"] = disintegrations["h"] * 3600 + else: + disintegrations["s"] = disintegrations[f"h {model}"] * 3600 + + print(f"Disintegrations in seconds:\n{disintegrations['s']}") + # Apply S-values and compute dose + dose_matrix = self.apply_s_value(disintegrations, svalues_df) + total_dose_per_target = dose_matrix.sum(axis=1) # Sum over source organs + + # Format output + dose_df = total_dose_per_target.to_frame(name="Absorbed dose (mGy/MBq)") + dose_df.index.name = "Target organ" + dose_df = dose_df.reset_index() + dose_df["Absorbed dose (Gy/MBq)"] = dose_df["Absorbed dose (mGy/MBq)"] / 1000 + + return dose_df + + def apply_s_value(self, tia_df, s_values): + """ + Multiply S-values by TIA to compute a dose matrix. + + Parameters + ---------- + tia_df : pandas.DataFrame + Disintegration times in seconds. + s_values : pandas.DataFrame + S-value table with target organs as rows and source organs as columns. + + Returns + ------- + pandas.DataFrame + Dose matrix indexed by target organ. + """ + common_source_organs = tia_df.index.intersection(s_values.columns) + + print(f"{len(common_source_organs)} source organs: {common_source_organs}") + + if common_source_organs.empty: + raise ValueError("No common source organs between TIA and S-value table.") + + # Subset both dataframes + tia_series = tia_df.loc[common_source_organs, "s"] + s_values_subset = s_values[common_source_organs] + + # Multiply S-values by corresponding TIA + dose_df = s_values_subset.multiply(tia_series, axis=1) + + return dose_df + + def create_mousecase( + self, + df, + method, + savefile=False, + dirname="./", + ): + """ + Create a pandas representation of the mouse case file used by OLINDA. + + The result is stored in ``self.mousecase`` and can optionally be persisted. + """ + filename = self.phantom.lower() + ".cas" + with _data_resource(f"olinda/templates/mouse/{filename}") as template_path: + template = pd.read_csv(template_path) + template.columns = ["Data"] + + # modify the isotope in the template + ind = template[template["Data"] == "[BEGIN NUCLIDES]"].index + template.loc[ind[0] + 1, "Data"] = self.isotope + "|" + input_organs = df.drop( + set(self.not_inphantom).intersection(set(df.index)) + ).index.to_list() + if "Residual" in input_organs: + input_organs.remove("Residual") + else: + pass + if "Tail" in input_organs: + input_organs.remove("Tail") + else: + pass + print(input_organs) + # change the kinetics for each input organ + for org in input_organs: # ignore the tumor here + + temporg = org + uptakepos = 2 + + if org == "Large Intestine": + temporg = "LLI" + elif org == "Skeleton": + temporg = "Bone" # think about trabecular vs cortical pos + uptakepos = 3 + elif org == "Remainder Body": + temporg = "Body" + elif org == "Heart": + temporg = "Heart" + uptakepos = 3 + + ind = template[template["Data"].str.contains(temporg)].index + print(org) + sourceorgan = template.iloc[ind[0]].str.split("|")[0][0] + massorgan = template.iloc[ind[0]].str.split("|")[0][1] + kineticdata = df.loc[org]["h"] + + if np.isnan(kineticdata): + kineticdata = 0 + + template.iloc[ind[0]] = ( + sourceorgan + "|" + massorgan + "|" + "{:7f}".format(kineticdata) + ) + template.iloc[ind[uptakepos]] = ( + sourceorgan + "|" + "{:7f}".format(kineticdata) + ) + + now = datetime.datetime.now() + template.columns = [ + "Saved on " + now.strftime("%m.%d.%Y") + " at " + now.strftime("%H:%M:%S") + ] + + self.mousecase = template + + if savefile is True: + if not path.exists(dirname): + makedirs(dirname) + + self.mousecase.to_csv(dirname + "/" + method + filename, index=False) + + print(f"The case file {filename} has been saved in\n{format(dirname)}") + + def rename_organ(self, oldname, newname): + """Rename an organ across biodistribution and disintegration tables.""" + ind_list = self.disintegrations_all_organs.index.tolist() + ind_pos = ind_list.index(oldname) + ind_list[ind_pos] = newname + self.disintegrations_all_organs.index = ind_list + + ind_list = self.biodi.index.tolist() + ind_pos = ind_list.index(oldname) + ind_list[ind_pos] = newname + self.biodi.index = ind_list + + def create_human(self, tumor_name=None): + """Convert the current biodistribution into the human phantom domain.""" + # We are mostly using the disintegrations_all_organs dataframe, but we adjust the biodi dataframe as well to match the human phantom structure + human = deepcopy(self) + human.phantom = "AdultHuman" + + if "Small Intestine" not in human.biodi.index: + human.biodi.loc["Small Intestine"] = human.biodi.loc["Large Intestine"] + human.disintegrations_all_organs.loc["Small Intestine"] = ( + human.disintegrations_all_organs.loc["Large Intestine"] + ) + print("Small Intestine added to the biodi") + else: + print("Small Intestine already in the biodi, no need to add it") + + if "Skeleton" in human.biodi.index: + human.rename_organ("Skeleton", "Bone Surfaces") + + if "Blood" in human.biodi.index: + human.rename_organ("Blood", "Heart Contents") + + if "Red Marrow" not in human.disintegrations_all_organs.index: + try: + human.disintegrations_all_organs.loc["Red Marrow", "h"] = ( + human.disintegrations_all_organs.loc["Heart Contents", "h"] * 0.34 + ) + except KeyError: + human.disintegrations_all_organs.loc["Red Marrow", "h Female"] = ( + human.disintegrations_all_organs.loc["Heart Contents", "h Female"] + * 0.34 + ) + human.disintegrations_all_organs.loc["Red Marrow", "h Male"] = ( + human.disintegrations_all_organs.loc["Heart Contents", "h Male"] + * 0.34 + ) + + if "Tail" in human.disintegrations_all_organs.index: + print( + "Tail is not modelled in the human phantom, removing it from the biodi" + ) + human.biodi = human.biodi.drop("Tail", axis=0) + human.disintegrations_all_organs = human.disintegrations_all_organs.drop( + "Tail", axis=0 + ) + + if "Tumor" in human.disintegrations_all_organs.index: + print( + "Tumor is not modelled in the human phantom, removing it from the biodi" + ) + human.biodi = human.biodi.drop("Tumor", axis=0) + human.disintegrations_all_organs = human.disintegrations_all_organs.drop( + "Tumor", axis=0 + ) + + with _data_resource( + "phantom/human/human_phantom_masses.csv" + ) as human_mass_path: + human.phantom_mass = pd.read_csv(human_mass_path) + human.phantom_mass.set_index("Organ", inplace=True) + human.phantom_mass.sort_index(inplace=True) + + with _data_resource( + "phantom/human/human_notinphantom_masses.csv" + ) as human_lit_path: + human.literature_mass = pd.read_csv(human_lit_path) + human.literature_mass.set_index("Organ", inplace=True) + + human.disintegrations_all_organs.sort_index(inplace=True) + print(human.disintegrations_all_organs) + if "h Female" not in human.disintegrations_all_organs: + human.disintegrations_all_organs.rename( + columns={"h": "h Male"}, inplace=True + ) + if "h Female" not in human.disintegrations_all_organs: + human.disintegrations_all_organs["h Female"] = ( + human.disintegrations_all_organs["h Male"] + ) + human.disintegrations_all_organs.loc["Rectum", "h Female"] = ( + 70 / 360 + ) * human.disintegrations_all_organs.loc["Large Intestine", "h Female"] + human.disintegrations_all_organs.loc["Rectum", "h Male"] = ( + 70 / 370 + ) * human.disintegrations_all_organs.loc["Large Intestine", "h Male"] + human.disintegrations_all_organs.loc["Left Colon", "h Female"] = ( + 145 / 360 + ) * human.disintegrations_all_organs.loc["Large Intestine", "h Female"] + human.disintegrations_all_organs.loc["Left Colon", "h Male"] = ( + 150 / 370 + ) * human.disintegrations_all_organs.loc["Large Intestine", "h Male"] + human.disintegrations_all_organs.loc["Right Colon", "h Female"] = ( + 145 / 360 + ) * human.disintegrations_all_organs.loc["Large Intestine", "h Female"] + human.disintegrations_all_organs.loc["Right Colon", "h Male"] = ( + 150 / 370 + ) * human.disintegrations_all_organs.loc["Large Intestine", "h Male"] + human.disintegrations_all_organs = human.disintegrations_all_organs.drop( + "Large Intestine", axis=0 + ) + print(human.disintegrations_all_organs) + human.not_inphantom = [] + + for ( + org + ) in ( + human.disintegrations_all_organs.index + ): # for org in self.disinteggrations.index: + if org not in human.phantom_mass.index: + human.not_inphantom.append(org) + + human.not_inphantom = list(set(human.not_inphantom) - set(["Remainder Body"])) + print( + "These organs from the biodi are not modelled in the phantom:\n{}".format( + human.not_inphantom + ) + ) + if tumor_name: + human.not_inphantom_notumor = [ + org for org in human.not_inphantom if tumor_name not in org + ] + else: + human.not_inphantom_notumor = human.not_inphantom + + human.disintegrations_all_organs.loc["Remainder Body", "h Female"] = sum( + human.disintegrations_all_organs.loc[ + human.not_inphantom_notumor, "h Female" + ] + ) + human.disintegrations_all_organs.loc["Remainder Body", "h Male"] = sum( + human.disintegrations_all_organs.loc[human.not_inphantom_notumor, "h Male"] + ) + + human.disintegrations_all_organs = human.disintegrations_all_organs[ + ["h Female", "h Male"] + ] + human.disintegrations_all_organs.drop( + human.not_inphantom, inplace=True + ) # Only organs that are in the phantom will be kept in the disintegrations dataframe and passed to olinda + human.disintegrations_all_organs.sort_index(inplace=True) + return human + + def apply_relative_mass_scaling(self, mouse_mass=25): + """Apply relative mass scaling factors to mouse disintegrations.""" + with _data_resource("phantom/mouse/rMSF_factor.csv") as rmsf_path: + rMSF_data = pd.read_csv(rmsf_path, index_col=0) + + female_mass_sum = rMSF_data.loc[self.not_inphantom_notumor, "Female"].sum() + male_mass_sum = rMSF_data.loc[self.not_inphantom_notumor, "Male"].sum() + mouse_mass_sum = rMSF_data.loc[ + self.not_inphantom_notumor, f"{mouse_mass}g_mouse" + ].sum() + + mouse_body_mass = rMSF_data.loc["Body", f"{mouse_mass}g_mouse"] + human_body_female = rMSF_data.loc["Body", "Female"] + human_body_male = rMSF_data.loc["Body", "Male"] + + remainder_correction_female = (mouse_body_mass / human_body_female) * ( + female_mass_sum / mouse_mass_sum + ) + remainder_correction_male = (mouse_body_mass / human_body_male) * ( + male_mass_sum / mouse_mass_sum + ) + + for organ in self.disintegrations_all_organs.index: + if organ != "Remainder Body": + rMSF_female = rMSF_data.loc[organ, f"rMSF_F_{mouse_mass}"] + rMSF_male = rMSF_data.loc[organ, f"rMSF_M_{mouse_mass}"] + + self.disintegrations_all_organs.loc[organ, "h Female"] *= rMSF_female + self.disintegrations_all_organs.loc[organ, "h Male"] *= rMSF_male + elif organ == "Remainder Body": + self.disintegrations_all_organs.loc[ + organ, "h Female" + ] *= remainder_correction_female + self.disintegrations_all_organs.loc[ + organ, "h Male" + ] *= remainder_correction_male + + def create_humancase(self, df, method, savefile=False, dirname="./"): + """ + Create a pandas representation of the human case file used by OLINDA. + + The result is stored in ``self.humancas`` and can optionally be saved. + """ + template_filename = ( + "adult_male.cas" if self.sex == "Male" else "adult_female.cas" + ) + with _data_resource( + f"olinda/templates/human/{template_filename}" + ) as template_path: + template = pd.read_csv(template_path) + + template.columns = ["Data"] + + # modify the isotope in the template + ind = template[template["Data"] == "[BEGIN NUCLIDES]"].index + template.loc[ind[0] + 1, "Data"] = self.isotope + "|" + + # change the kinetics for each input organ + for org in df.drop( + set(self.not_inphantom).intersection(set(df.index)) + ).index: # ignore the tumor here + temporg = org + uptakepos = 2 + if org == "Left Colon": + temporg = "ULI" + elif org == "Right Colon": + temporg = "LLI" + elif org == "Bone Surfaces": + temporg = "Bone" # think about trabecular vs cortical pos + uptakepos = 3 + elif org == "Remainder Body": + temporg = "Body" + elif org == "Heart": + temporg = "Heart Wall" + elif org == "Heart Contents": + temporg = "Heart Contents" + uptakepos = 1 + + ind = template[template["Data"].str.contains(temporg)].index + sourceorgan = template.iloc[ind[0]].str.split("|")[0][0] + massorgan = template.iloc[ind[0]].str.split("|")[0][1] + kineticdata = df.loc[org]["h " + self.sex] + + if np.isnan(kineticdata): + kineticdata = 0 + + template.iloc[ind[0]] = ( + sourceorgan + "|" + massorgan + "|" + "{:7f}".format(kineticdata) + ) + template.iloc[ind[uptakepos]] = ( + sourceorgan + "|" + "{:7f}".format(kineticdata) + ) + + now = datetime.datetime.now() + template.columns = [ + "Saved on " + now.strftime("%m.%d.%Y") + " at " + now.strftime("%H:%M:%S") + ] + + self.humancase = template + + if savefile is True: + if not path.exists(dirname): + makedirs(dirname) + + self.humancase.to_csv( + dirname + "/" + self.sex + method + ".cas", index=False + ) + + print( + "The case file {} has been saved in\n{}".format(self.sex + ".cas", dirname) + ) + + def scale_biexponential_tiac(self, row, biol_lambda_SF=0.25): + """Scale biexponential fits to enforce biological clearance constraints.""" + Cm_organ_t0_1 = row["bi_exp1:%ID"] / 100 + Cm_organ_t0_2 = row["bi_exp2:%ID"] / 100 + + # exp1 = row["bi_exp1:%ID"] / row["bi_exp:lambda_effective1_1/h"] # TODO: Use this variable or remove (flake8) + # exp2 = row["bi_exp2:%ID"] / row["bi_exp:lambda_effective2_1/h"] # TODO: Use this variable or remove (flake8) + # sum = exp1 + exp2 # TODO: Use this variable or remove (flake8) + # frac_exp1 = exp1 / sum # TODO: Use this variable or remove (flake8) + # frac_exp2 = exp2 / sum # TODO: Use this variable or remove (flake8) + + lambda_effective1 = row["bi_exp:lambda_effective1_1/h"] + lambda_effective2 = row["bi_exp:lambda_effective2_1/h"] + + lambda_physical = log(2) / self.half_life # 1/h + + lambda_biological1 = lambda_effective1 - lambda_physical + lambda_biological2 = lambda_effective2 - lambda_physical + + wb_m = self.wb_m + k_b_male = (73000 / wb_m) ** biol_lambda_SF + k_b_female = (60000 / wb_m) ** biol_lambda_SF + + TIAC_h_bi_male = ( + (Cm_organ_t0_1) + / (((k_b_male) ** (-1) * lambda_biological1 + lambda_physical)) + ) + ( + (Cm_organ_t0_2) + / ((k_b_male) ** (-1) * (lambda_biological2) + lambda_physical) + ) + + TIAC_h_bi_female = ( + (Cm_organ_t0_1) + / (((k_b_female) ** (-1) * lambda_biological1 + lambda_physical)) + ) + ( + (Cm_organ_t0_2) + / ((k_b_female) ** (-1) * (lambda_biological2) + lambda_physical) + ) + + return TIAC_h_bi_male, TIAC_h_bi_female + + def scale_monoexponential_tiac(self, row, biol_lambda_SF=0.25): + """Scale monoexponential fits to enforce biological clearance constraints.""" + lambda_effective = row["mono_exp:lambda_effective_1/h"] + lambda_physical = log(2) / self.half_life # 1/h + + lambda_biological = lambda_effective - lambda_physical + + Cm_organ_t0 = row["mono_exp:%ID"] / 100 + + wb_m = self.wb_m + k_b_male = (73000 / wb_m) ** biol_lambda_SF + + k_b_female = (60000 / wb_m) ** biol_lambda_SF + + TIAC_h_mono_male = Cm_organ_t0 / ( + k_b_male ** (-1) * (lambda_biological) + lambda_physical + ) + TIAC_h_mono_female = Cm_organ_t0 / ( + k_b_female ** (-1) * (lambda_biological) + lambda_physical + ) + + return TIAC_h_mono_male, TIAC_h_mono_female + + def lambda_biological_scaling(self, biol_lambda_SF=0.25, tumor_name=None): + """Apply biological lambda scaling to the stored fits and return a new BioDose instance.""" + print("At this point we are ignoring the tumor") + if tumor_name: + self.not_inphantom_notumor = [ + org for org in self.not_inphantom if tumor_name not in org + ] + # tumortemp = self.biodi.loc[tumor_name] # TODO: Use this variable or remove (flake8) + else: + self.not_inphantom_notumor = self.not_inphantom + self.not_inphantom_notumor + print(self.phantom) + print("Disintegrations\n") + + fit_accepted_df = pd.DataFrame( + list(self.fit_accepted.items()), columns=["Organ", "fit_accepted"] + ) + self.fitting_parameters = self.fitting_parameters.merge( + fit_accepted_df, on="Organ" + ) + self.fitting_parameters.set_index("Organ", inplace=True) + + if "mouse" in self.phantom.lower(): + self.fitting_parameters["mono_exp:%ID"] = ( + self.fitting_parameters["mono_exp:%ID/g"] + * self.phantom_mass[self.mouse_mass] + ) + self.fitting_parameters["bi_exp1:%ID"] = ( + self.fitting_parameters["bi_exp:1_%ID/g"] + * self.phantom_mass[self.mouse_mass] + ) + self.fitting_parameters["bi_exp2:%ID"] = ( + self.fitting_parameters["bi_exp:2_%ID/g"] + * self.phantom_mass[self.mouse_mass] + ) + if "Residual" in self.fitting_parameters.index: + self.not_inphantom_notumor.remove("Residual") + self.fitting_parameters.loc["Remainder Body", "mono_exp:%ID"] = ( + self.fitting_parameters["mono_exp:%ID/g"] + .loc[self.not_inphantom_notumor] + .mul( + self.literature_mass[self.mouse_mass].loc[ + self.not_inphantom_notumor + ] + ) + .sum() + ) + ( + self.disintegrations.loc["Residual", "%ID/g*h"] + * (self.phantom_mass.loc["Residual", self.mouse_mass]) + ) + else: + self.fitting_parameters.loc["Remainder Body", "mono_exp:%ID"] = ( + self.fitting_parameters["mono_exp:%ID/g"] + .loc[self.not_inphantom_notumor] + .mul(self.literature_mass[self.mouse_mass]) + .sum() + ) + self.fitting_parameters.loc["Remainder Body", "bi_exp1:%ID"] = ( + self.fitting_parameters["bi_exp:1_%ID/g"] + .loc[self.not_inphantom_notumor] + .mul(self.literature_mass[self.mouse_mass]) + .sum() + ) + self.fitting_parameters.loc["Remainder Body", "bi_exp2:%ID"] = ( + self.fitting_parameters["bi_exp:2_%ID/g"] + .loc[self.not_inphantom_notumor] + .mul(self.literature_mass[self.mouse_mass]) + .sum() + ) + + for org in self.not_inphantom_notumor: + if org != "Tail": + print(org) + self.fitting_parameters.loc[org, "mono_exp:%ID"] = ( + self.fitting_parameters.loc[org, "mono_exp:%ID/g"] + * self.literature_mass.loc[org, self.mouse_mass] + ) + self.fitting_parameters.loc[org, "bi_exp1:%ID"] = ( + self.fitting_parameters.loc[org, "bi_exp:1_%ID/g"] + * self.literature_mass.loc[org, self.mouse_mass] + ) + self.fitting_parameters.loc[org, "bi_exp2:%ID"] = ( + self.fitting_parameters.loc[org, "bi_exp:2_%ID/g"] + * self.literature_mass.loc[org, self.mouse_mass] + ) + else: + pass + print(self.fitting_parameters) + if "Tail" in self.fitting_parameters.index: + self.fitting_parameters = self.fitting_parameters.drop("Tail", axis=0) + + if "Tumor" in self.fitting_parameters.index: + self.fitting_parameters = self.fitting_parameters.drop("Tumor", axis=0) + + for i, organ in enumerate(self.fitting_parameters.index): + if self.fitting_parameters.loc[organ, "fit_accepted"] == 1.0: + TIAC_h_mono_male, TIAC_h_mono_female = self.scale_monoexponential_tiac( + self.fitting_parameters.iloc[i], biol_lambda_SF + ) + self.fitting_parameters.loc[organ, "h Male"] = TIAC_h_mono_male + self.fitting_parameters.loc[organ, "h Female"] = TIAC_h_mono_female + + elif self.fitting_parameters.loc[organ, "fit_accepted"] == 2.0: + TIAC_h_bi_male, TIAC_h_bi_female = self.scale_biexponential_tiac( + self.fitting_parameters.iloc[i], biol_lambda_SF + ) + self.fitting_parameters.loc[organ, "h Male"] = TIAC_h_bi_male + self.fitting_parameters.loc[organ, "h Female"] = TIAC_h_bi_female + + self.disintegrations_all_organs = self.fitting_parameters[ + ["h Female", "h Male"] + ] diff --git a/pytheranostics/qc/__init__.py b/pytheranostics/qc/__init__.py new file mode 100644 index 0000000..3731650 --- /dev/null +++ b/pytheranostics/qc/__init__.py @@ -0,0 +1 @@ +"""PyTheranostics package.""" diff --git a/pytheranostics/qc/dosecal_qc.py b/pytheranostics/qc/dosecal_qc.py new file mode 100644 index 0000000..35ceab6 --- /dev/null +++ b/pytheranostics/qc/dosecal_qc.py @@ -0,0 +1,235 @@ +"""Quality-control routines for dose calibrator submissions.""" + +import numpy as np + +from pytheranostics.qc.qc import QC +from pytheranostics.shared.evaluation_metrics import perc_diff +from pytheranostics.shared.radioactive_decay import decay_act + + +class DosecalQC(QC): + """Perform QC checks for dose calibrator calibration data.""" + + def __init__(self, isotope, db_dic, cal_type="dc"): + """Initialize the QC helper with isotope metadata and DB extracts.""" + super().__init__(isotope, db_dic=db_dic, cal_type=cal_type) + + def check_calibration(self, accepted_percent=1.5, accepted_recovery=(97, 103)): + """Run the full calibration workflow and append findings to the summary.""" + # keep a flag to accept or reject depending on the different tests. Default is to accept (1): + # If something fails it will be changed to 2 if needs to verify and 3 if it completely fails + + self.accepted_calibration = 1 + + self.append_to_summary( + f"QC for Dose Calibrator Calibration of {self.isotope}:\t \n\n" + ) + + # check that the expected activity matches the decay of the: + + # Shipped Sources + self.check_source_decay(accepted_percent=accepted_percent) + + # 20 ml syringe + self.check_syringe_recovery() + + # check if reported recovery matches the calculated one and if they fall within the approved range + + if ( + self.db_df["cal_data"]["recovery_calculated"] + == self.db_df["cal_data"]["reported_recovery"] + ).all(axis=None): + if ( + (accepted_recovery[0] <= self.db_df["cal_data"]["recovery_calculated"]) + & ( + accepted_recovery[1] + >= self.db_df["cal_data"]["recovery_calculated"] + ) + ).all(axis=None): + self.append_to_summary( + f"The reported recovery matches the calculated recovery and falls within {accepted_recovery[0]} % and {accepted_recovery[1]} %.\t OK\n\n" + ) + + else: + self.accepted_calibration = 3 + self.append_to_summary( + f"The reported recovery matches the calculated recovery but is out of the acceptable range of {accepted_recovery[0]} % to {accepted_recovery[1]} %.\t FAIL\n" + ) + self.append_to_summary("\nPlease see below:\t \n\n") + mismatch_df = self.db_df["cal_data"][ + self.db_df["cal_data"]["recovery_calculated"] + != self.db_df["cal_data"]["reported_recovery"] + ] + cols_show = [ + "performed_by", + "manufacturer", + "model", + "source_id", + "ref_act_MBq", + "decayed_ref", + "Ai_syr_MBq", + "Af_syr_MBq", + "As_syr_MBq", + "Am_syr_MBq", + "reported_recovery", + "recovery_calculated", + ] + self.append_to_summary(f"{mismatch_df[cols_show].to_string()}\t ") + + else: + if ( + (accepted_recovery[0] <= self.db_df["cal_data"]["recovery_calculated"]) + & ( + accepted_recovery[1] + >= self.db_df["cal_data"]["recovery_calculated"] + ) + ).all(axis=None): + self.accepted_calibration = 2 + self.append_to_summary( + f"One or more reported recovery does not match the calculated recovery. However, the calculated recovery is within the accepted range of {accepted_recovery[0]} % and {accepted_recovery[1]} %.\t VERIFY\n" + ) + # self.append_to_summary(f'Please see below:\t \n\n') + else: + self.accepted_calibration = 3 + self.append_to_summary( + f"One or more reported recovery does not match the calculated recovery and the calculated recovery is out of the acceptable range of {accepted_recovery[0]} % to {accepted_recovery[1]} %.\t FAIL\n" + ) + # self.append_to_summary(f'Please see below:\t \n\n') + # print(self.db_df['cal_data'].columns) + mismatch_df = self.db_df["cal_data"][ + self.db_df["cal_data"]["recovery_calculated"] + != self.db_df["cal_data"]["reported_recovery"] + ] + cols_show = [ + "manufacturer", + "model", + "source_id", + "delta_t", + "ref_act_MBq", + "decayed_ref", + "Ae_MBq", + "decay_perc_diff", + "Am_MBq", + "Ai_syr_MBq", + "Af_syr_MBq", + "As_syr_MBq", + "Am_syr_MBq", + "reported_recovery", + "recovery_calculated", + "syringe_activity_calculated", + ] + # print(f"\n\n{mismatch_df[cols_show]}") + print(f"\n\n{self.db_df['cal_data'][cols_show]}") + + # if self.accepted_calibration == 1: + # self.append_to_summary(f"The following are the accepted calibration numbers for the dose calibrators submitted:\n\n") + self.accepted_calnum_df = ( + self.db_df["cal_data"][ + ["manufacturer", "model", "source_id", "final_cal_num"] + ] + .groupby(["manufacturer", "model", "source_id"]) + .sum() + ) + # self.append_to_summary(f"{summary_df.to_string()}") + + self.print_summary() + + def check_source_decay(self, accepted_percent): + """Validate decay corrections for each shipped source.""" + # find the shipped sources + sources = self.db_df["shipped_data"].source_id.unique() + + for s in sources: + # find the reference time of the shipped source + ref_act = self.db_df["shipped_data"][ + self.db_df["shipped_data"].source_id == s + ]["A_ref_MBq"] + ref_time = self.db_df["shipped_data"][ + self.db_df["shipped_data"].source_id == s + ]["ref_datetime"] + + # calculate the delta time of the calibration of the shipped source and the measurement + self.db_df["cal_data"].loc[ + self.db_df["cal_data"].source_id == s, "delta_t" + ] = ( + self.db_df["cal_data"][self.db_df["cal_data"]["source_id"] == s][ + "measurement_datetime" + ] + - ref_time.values[0] + ) / np.timedelta64( + 1, "D" + ) + self.db_df["cal_data"].loc[ + self.db_df["cal_data"].source_id == s, "ref_act_MBq" + ] = ref_act.values[0] + + self.db_df["cal_data"]["decayed_ref"] = np.nan + + # decay correct reference sources to measurement time series + self.db_df["cal_data"].decayed_ref = self.db_df["cal_data"].apply( + lambda row: decay_act( + row.ref_act_MBq, row.delta_t, self.isotope_dic["half_life"] + ), + axis=1, + ) + + # Check that decay has been applied correctly. calculate perc_diff + self.db_df["cal_data"]["decay_perc_diff"] = perc_diff( + self.db_df["cal_data"]["Ae_MBq"], self.db_df["cal_data"].decayed_ref + ) + + # check recovery with the calculated decayed activity + self.db_df["cal_data"]["recovery_calculated"] = ( + self.db_df["cal_data"].Am_MBq / self.db_df["cal_data"]["decayed_ref"] * 100 + ).round(1) + + # check if any perc_diff are higher than accepted + if (self.db_df["cal_data"]["decay_perc_diff"].abs() > accepted_percent).any( + axis=None + ): + self.accepted_calibration = 2 + self.append_to_summary( + f"One or more of the sources decay correction are off by more than {accepted_percent} %.\t MISMATCH\n\n" + ) + # print(f"{self.db_df['cal_data'][['source_id','measurement_datetime','delta_t','ref_act_MBq','Am_MBq','Ae_MBq','decayed_ref','decay_perc_diff']].to_string()}") + + else: + self.append_to_summary( + f"All the sources decay correction are within {accepted_percent} % of the expected.\t OK\n\n" + ) + + def check_syringe_recovery(self, syringe_name="syringe_20_mL"): + """Verify the syringe recovery curve stays within allowed tolerances.""" + self.db_df["cal_data"].loc[ + self.db_df["cal_data"].source_id == syringe_name, + "syringe_activity_calculated", + ] = ( + self.db_df["cal_data"]["Ai_syr_MBq"] - self.db_df["cal_data"]["Af_syr_MBq"] + ) + self.db_df["cal_data"].loc[ + self.db_df["cal_data"].source_id == syringe_name, "recovery_calculated" + ] = ( + self.db_df["cal_data"]["Am_syr_MBq"] + / self.db_df["cal_data"]["syringe_activity_calculated"] + * 100 + ).round( + 1 + ) + + # check if the expected activity in syringe is correct + syr_df = self.db_df["cal_data"][ + self.db_df["cal_data"].source_id == syringe_name + ] + + if (syr_df.syringe_activity_calculated == syr_df.As_syr_MBq).all(axis=None): + self.append_to_summary( + "The reported activity expected in the syringe matches the calculation.\t OK\n\n" + ) + else: + self.accepted_calibration = 2 + self.append_to_summary( + "The reported activity expected in the syringe does not match the calculation.\t MISMATCH\n\n" + ) + # mismatch_df = syr_df[syr_df['syringe_activity_calculated'] != syr_df['As_syr_MBq']] + # cols_show = ['performed_by','manufacturer','model','source_id','Ai_syr_MBq','Af_syr_MBq','As_syr_MBq','syringe_activity_calculated'] + # self.append_to_summary(f"{mismatch_df[cols_show].to_string()} \n\n") diff --git a/pytheranostics/qc/planar_qc.py b/pytheranostics/qc/planar_qc.py new file mode 100644 index 0000000..176abb9 --- /dev/null +++ b/pytheranostics/qc/planar_qc.py @@ -0,0 +1,80 @@ +"""QC checks for planar acquisitions.""" + +import pydicom + +from pytheranostics.qc.qc import QC + + +class PlanarQC(QC): + """QC checks specific to planar acquisitions.""" + + def __init__(self, isotope, dicomfile, db_dic, cal_type="planar"): + """Load the planar DICOM file and associated calibration forms.""" + super().__init__(isotope, db_dic=db_dic, cal_type=cal_type) + self.ds = pydicom.dcmread(dicomfile) + + def check_windows_energy(self): + """Run the planar QC workflow and populate the summary.""" + self.append_to_summary(f"QC for planar scan of {self.isotope}:\n\n") + + self.check_camera_parameters() + + self.print_summary() + + def check_camera_parameters(self): + """Verify DICOM acquisition parameters match the expected protocol.""" + camera_manufacturer = self.ds.Manufacturer + camera_model = self.ds.ManufacturerModelName + acquisition_date = self.ds.AcquisitionDate + acquisition_time = self.ds.AcquisitionTime + modality = self.ds.Modality + duration = (self.ds.ActualFrameDuration / 1000) / 60 + rows = self.ds.Rows + cols = self.ds.Columns + zoom = self.ds.DetectorInformationSequence[0].ZoomFactor + + self.append_to_summary(f"CAMERA: {camera_manufacturer} {camera_model}\t \n") + self.append_to_summary(f"MODALITY: {modality}\t \n") + self.append_to_summary( + f"Scan performed on: {acquisition_date} at {acquisition_time}\t \n\n" + ) + + # check duration + if round(duration) == self.isotope_dic["planar"]["duration"]: + self.append_to_summary(f"SCAN DURATION: {round(duration)} minutes\tOK\n\n") + else: + self.append_to_summary( + f'SCAN DURATION: {duration} minutes (should be {self.isotope_dic["planar"]["duration"]} mins)\tMISMATCH\n\n' + ) + + # check windows + self.window_check_df = self.window_check(type="planar") + + # check collimator + accepted_collimators = self.isotope_dic["accepted_collimators"] + if (len(self.db_df["cal_data"]["collimator"].unique()) == 1) & ( + self.db_df["cal_data"]["collimator"].unique()[0] in accepted_collimators + ): + self.append_to_summary( + f"COLLIMATOR: {self.db_df['cal_data']['collimator'].unique()[0]}\t OK\n\n" + ) + else: + self.append_to_summary( + f"COLLIMATOR: {self.db_df['cal_data']['collimator'].unique()[0]} is not in the accepted collimator list.\t VERIFY\n\n" + ) + + # check matrix size + if [rows, cols] == self.isotope_dic["planar"]["matrix"]: + self.append_to_summary(f"MATRIX SIZE: {rows} x {cols}\t OK\n") + else: + self.append_to_summary( + f'MATRIX SIZE: {rows} x {cols} (should be {self.isotope_dic["planar"]["matrix"][0]} x {self.isotope_dic["planar"]["matrix"][1]}\t MISMATCH\n' + ) + + # check zoom + if zoom == self.isotope_dic["planar"]["zoom"]: + self.append_to_summary(f"ZOOM: {zoom}\t OK\n\n") + else: + self.append_to_summary( + f'ZOOM: {zoom} (should be {self.isotope_dic["planar"]["zoom"]}\tMISMATCH\n\n' + ) diff --git a/pytheranostics/qc/qc.py b/pytheranostics/qc/qc.py new file mode 100644 index 0000000..2e76b6f --- /dev/null +++ b/pytheranostics/qc/qc.py @@ -0,0 +1,328 @@ +"""Shared utilities for QC workflows.""" + +import json +from io import StringIO +from pathlib import Path + +import numpy as np +import pandas as pd + +from pytheranostics.shared.evaluation_metrics import perc_diff +from pytheranostics.shared.radioactive_decay import decay_act + +this_dir = Path(__file__).resolve().parent.parent +ISOTOPE_DATA_FILE = Path(this_dir, "data", "isotopes.json") + + +class QC: + """Base class for QC workflows (planar, SPECT, dose calibrator).""" + + def __init__(self, isotope, **kwargs): + """Load isotope metadata and qualifying site data required for QC.""" + self.db_df = {} + + with open(ISOTOPE_DATA_FILE) as f: + self.isotope_dic = json.load(f) + + self.isotope = isotope + self.isotope_dic = self.isotope_dic[isotope] + self.summary = "" + + if "db_dic" in kwargs: + db_file = kwargs["db_dic"]["db_file"] + sheet_names = kwargs["db_dic"]["sheet_names"] + header = kwargs["db_dic"]["header"] + if "site_id" in kwargs["db_dic"]: + site_id = kwargs["db_dic"]["site_id"] + + if "cal_type" in kwargs: + cal_type = kwargs["cal_type"] + + db_df = pd.read_excel(db_file, sheet_name=sheet_names, header=header) + + if "calibration_data" in db_df.keys(): + cal_forms = db_df[sheet_names[0]] + + # convert columns of date and time to datetime + cols = ["measurement_datetime", "ref_time"] + + for c in cols: + cal_forms[c] = pd.to_datetime(cal_forms[c], format="%Y%m%d %H:%M") + + self.db_df["cal_data"] = cal_forms + + else: + cal_forms = db_df[sheet_names[0]] + ref_shipped = db_df[sheet_names[1]] + + # convert columns of date and time to datetime + cal_forms["measurement_datetime"] = pd.to_datetime( + cal_forms["measurement_datetime"], format="%Y%m%d %H:%M" + ) + ref_shipped["ref_datetime"] = pd.to_datetime( + ref_shipped["ref_datetime"], format="%Y%m%d %H:%M" + ) + + # only look at the center being qualified and the type of calibration necessary + if "site_id" in kwargs["db_dic"]: + self.db_df["cal_data"] = cal_forms[ + (cal_forms.site_id == site_id) + & (cal_forms.cal_type == cal_type) + ] + self.db_df["shipped_data"] = ref_shipped[ + (ref_shipped.site_id == site_id) + ] + else: + self.db_df["cal_data"] = cal_forms + self.db_df["shipped_data"] = ref_shipped + + def window_check(self, win_perdiff_max=2, type="planar"): + """Compare configured energy windows against protocol tolerances.""" + if type == "planar": + ds = self.ds + elif type == "spect": + ds = self.recon_ds + elif type == "raw": + ds = self.proj_ds + + win = [] + for i in range(len(ds.EnergyWindowInformationSequence)): + low = ( + ds.EnergyWindowInformationSequence[i] + .EnergyWindowRangeSequence[0] + .EnergyWindowLowerLimit + ) + upper = ( + ds.EnergyWindowInformationSequence[i] + .EnergyWindowRangeSequence[0] + .EnergyWindowUpperLimit + ) + center = (low + upper) / 2 + + win.append((low, center, upper)) + + # check if the expected windows are found in the image being analyzed + win_check = {} + # first find if the centers correspond to the different expected windows and labeled those windows accordingly + for el in win: + for k, w in self.isotope_dic["windows_kev"].items(): + if int(el[1]) in range(round(w[0]), round(w[2])): + win_check[k] = el + + # find which expected windows are not in the current dataset, and which windows are in both + missing_win_keys = list( + set(self.isotope_dic["windows_kev"].keys()) - set(win_check) + ) + common_win_keys = set(self.isotope_dic["windows_kev"].keys()).intersection( + set(win_check) + ) + + if type != "spect": + if missing_win_keys: + self.append_to_summary( + f"The dataset is missing a window for {missing_win_keys}.\tMISMATCH\n" + ) + else: + self.append_to_summary( + "The dataset contains all the expected energy windows.\tOK\n" + ) + else: + if "photopeak" in common_win_keys: + self.append_to_summary( + f"The reconstructed image is for the correct photopeak, centered at {win_check['photopeak'][1]} keV.\tOK\n" + ) + + # find differences between common windows + win_perc_dif = {} + for k in common_win_keys: + expected = np.array(self.isotope_dic["windows_kev"][k]) + configured = np.array(win_check[k]) + + win_perc_dif[k] = np.round((configured - expected) / expected * 100, 2) + + win_perc_dif.update(dict.fromkeys(missing_win_keys, np.nan)) + + protocol_df = pd.DataFrame.from_dict(self.isotope_dic["windows_kev"]).T + + win_df = pd.DataFrame.from_dict(win_check).T + + perc_diff_df = pd.DataFrame.from_dict(win_perc_dif).T + + arrays = [ + [ + "expected", + "expected", + "expected", + "configured", + "configured", + "configured", + "perc_diff", + "perc_diff", + "perc_diff", + ], + [ + "min", + "center", + "upper", + "min", + "center", + "upper", + "min", + "center", + "upper", + ], + ] + + if type != "spect": + # check if any perc_diff are higher than 2% + if (perc_diff_df.abs() > win_perdiff_max).any(axis=None): + self.append_to_summary( + f"There are differences in the energy window settings that are higher than {win_perdiff_max} %.\nPlease see below:\t MISMATCH\n\n" + ) + # self.append_to_summary(f'{self.window_check_df.to_string()}') + elif (perc_diff_df.abs() != 0).any(axis=None): + self.append_to_summary( + "There are differences in the energy window settings but are minimal and acceptable.\t VERIFY\n\n" + ) + # self.append_to_summary(f'{self.window_check_df.to_string()}') + else: + self.append_to_summary( + "The energy window settings are set as expected.\t OK\n\n" + ) + # self.append_to_summary(f'{self.window_check_df.to_string()}') + + window_check_df = pd.concat([protocol_df, win_df, perc_diff_df], axis=1) + window_check_df.columns = pd.MultiIndex.from_arrays(arrays) + + if type == "spect": + window_check_df.dropna(inplace=True) + + return window_check_df + + def append_to_summary(self, text): + """Append formatted text to the QC summary buffer.""" + self.summary = self.summary + text + + def print_summary(self): + """Convert the text summary into a styled pandas DataFrame.""" + # print(self.summary) + summary = StringIO(self.summary) + self.summary_df = pd.read_csv(summary, sep="\t") + # print(self.summary_df.columns[0]) + cols = self.summary_df.columns[0] + # print(cols) + # self.summary_df.style.set_properties(subset=[cols],**{'width': '500px'},**{'text-align': 'left'}).hide_index() # https://stackoverflow.com/questions/55051920/pandas-styler-object-has-no-attribute-hide-index-error + self.summary_df.style.set_properties( + subset=[cols], **{"width": "500px"}, **{"text-align": "left"} + ).hide() + # print(self.summary) + + def update_db(self, syringe_name="syringe_20_mL"): + """Refresh calibration tables with decay-corrected references and recoveries.""" + sources = self.db_df["shipped_data"].source_id.unique() + centres = self.db_df["shipped_data"].site_id.unique() + + for s in sources: + for c in centres: + # find the reference time of the shipped source + ref_act = self.db_df["shipped_data"][ + (self.db_df["shipped_data"].source_id == s) + & (self.db_df["shipped_data"].site_id == c) + ]["A_ref_MBq"] + ref_time = self.db_df["shipped_data"][ + (self.db_df["shipped_data"].source_id == s) + & (self.db_df["shipped_data"].site_id == c) + ]["ref_datetime"] + # print(self.db_df['cal_data'].loc[(self.db_df['cal_data'].source_id == s) & (self.db_df['shipped_data'].site_id == c)]) + + self.db_df["cal_data"].loc[ + ( + (self.db_df["cal_data"].source_id == s) + & (self.db_df["cal_data"].site_id == c) + ), + "ref_act_MBq", + ] = ref_act.values[0] + self.db_df["cal_data"].loc[ + ( + (self.db_df["cal_data"].source_id == s) + & (self.db_df["cal_data"].site_id == c) + ), + "ref_time", + ] = ref_time.values[0] + + # calculate the delta time of the calibration of the shipped source and the measurement + self.db_df["cal_data"]["delta_t"] = ( + self.db_df["cal_data"]["measurement_datetime"] + - self.db_df["cal_data"]["ref_time"] + ) / np.timedelta64(1, "D") + + self.db_df["cal_data"]["decayed_ref"] = np.nan + + # decay correct reference sources to measurement time series + self.db_df["cal_data"].decayed_ref = self.db_df["cal_data"].apply( + lambda row: decay_act( + row.ref_act_MBq, row.delta_t, self.isotope_dic["half_life"] + ), + axis=1, + ) + + # # Check that decay has been applied correctly. calculate perc_diff + self.db_df["cal_data"]["decay_perc_diff"] = perc_diff( + self.db_df["cal_data"]["Ae_MBq"], self.db_df["cal_data"].decayed_ref + ) + + # #check recovery with the calculated decayed activity + self.db_df["cal_data"]["recovery_calculated"] = ( + self.db_df["cal_data"].Am_MBq / self.db_df["cal_data"]["decayed_ref"] * 100 + ).round(1) + + # check the syringe + self.db_df["cal_data"].loc[ + self.db_df["cal_data"].source_id == syringe_name, + "syringe_activity_calculated", + ] = ( + self.db_df["cal_data"]["Ai_syr_MBq"] - self.db_df["cal_data"]["Af_syr_MBq"] + ) + self.db_df["cal_data"].loc[ + self.db_df["cal_data"].source_id == syringe_name, "recovery_calculated" + ] = ( + self.db_df["cal_data"]["Am_syr_MBq"] + / self.db_df["cal_data"]["syringe_activity_calculated"] + * 100 + ).round( + 1 + ) + + # reorder columns + cols = [ + "site_id", + "department", + "city", + "performed_by", + "cal_type", + "manufacturer", + "model", + "collimator", + "initial_ cal_num", + "source_id", + "measurement_datetime", + "time_zone", + "ref_time", + "ref_act_MBq", + "delta_t", + "decayed_ref", + "Ae_MBq", + "decay_perc_diff", + "Am_MBq", + "Ai_syr_MBq", + "Af_syr_MBq", + "As_syr_MBq", + "syringe_activity_calculated", + "Am_syr_MBq", + "reported_recovery", + "recovery_calculated", + "final_cal_num", + "comments", + ] + + self.db_df["cal_data"] = self.db_df["cal_data"][cols] diff --git a/pytheranostics/qc/spect_qc.py b/pytheranostics/qc/spect_qc.py new file mode 100644 index 0000000..9f774d4 --- /dev/null +++ b/pytheranostics/qc/spect_qc.py @@ -0,0 +1,195 @@ +"""QC checks for SPECT projections and reconstructions.""" + +import numpy as np +import pydicom + +from pytheranostics.qc.qc import QC + + +class SPECTQC(QC): + """QC checks for raw SPECT projections and reconstructed images.""" + + def __init__(self, isotope, projections_file, recon_file, db_dic, cal_type="spect"): + """Load projection and reconstruction DICOM datasets for QC.""" + super().__init__(isotope, db_dic=db_dic, cal_type=cal_type) + self.proj_ds = pydicom.dcmread(projections_file) + self.recon_ds = pydicom.dcmread(recon_file) + + def check_projs(self): + """Run the full QC pipeline for projections and reconstructed images.""" + self.window_check_df = {} + + self.append_to_summary(f"QC for SPECT RAW DATA of {self.isotope}:\n\n") + + # check acquisition parameters (i.e. projections) + self.check_camera_parameters(self.proj_ds, projs=True) + + # check image parameters (i.e.reconsturcte) + self.check_camera_parameters(self.recon_ds, projs=False) + + self.print_summary() + + def check_camera_parameters(self, ds, projs=True): + """Append camera parameter checks for either projection or reconstruction.""" + camera_manufacturer = ds.Manufacturer + camera_model = ds.ManufacturerModelName + acquisition_date = ds.AcquisitionDate + acquisition_time = ds.AcquisitionTime + modality = ds.Modality + try: + duration = ds.RotationInformationSequence[0].ActualFrameDuration / 1000 + except (AttributeError, IndexError): + duration = np.nan + rows = ds.Rows + cols = ds.Columns + try: + zoom = ds.DetectorInformationSequence[0].ZoomFactor + except (AttributeError, IndexError): + zoom = None + try: + number_projections = ( + ds.RotationInformationSequence[0].NumberOfFramesInRotation + * ds.NumberOfDetectors + ) + except (AttributeError, IndexError): + number_projections = np.nan + + if projs: + self.append_to_summary(f"CAMERA: {camera_manufacturer} {camera_model}\t \n") + self.append_to_summary(f"MODALITY: {modality}\t \n") + self.append_to_summary( + f"Scan performed on: {acquisition_date} at {acquisition_time}\t \n\n" + ) + self.append_to_summary(" \t \n\n") + + self.append_to_summary("PROJECTIONS:\t \n") + + # check number of projections + if ( + number_projections + == self.isotope_dic["raw"]["n_proj"][ + camera_manufacturer.lower().split(" ")[0] + ] + ): + self.append_to_summary( + f"NUMBER OF PROJECTIONS: {number_projections} of {self.isotope_dic['raw']['n_proj'][camera_manufacturer.lower().split(' ')[0]]}\t OK\n\n" + ) + else: + self.append_to_summary( + f"NUMBER OF PROJECTIONS: {number_projections} of {self.isotope_dic['raw']['n_proj'][camera_manufacturer.lower().split(' ')[0]]}\t VERIFY\n\n" + ) + + # check collimator + accepted_collimators = self.isotope_dic["accepted_collimators"] + if (len(self.db_df["cal_data"]["collimator"].unique()) == 1) & ( + self.db_df["cal_data"]["collimator"].unique()[0] in accepted_collimators + ): + self.append_to_summary( + f"COLLIMATOR: {self.db_df['cal_data']['collimator'].unique()[0]}\t OK\n\n" + ) + else: + self.append_to_summary( + f"COLLIMATOR: {self.db_df['cal_data']['collimator'].unique()[0]} is not in the accepted collimator list.\t VERIFY\n\n" + ) + + # check duration + if round(duration) == self.isotope_dic["raw"]["duration_per_proj"]: + self.append_to_summary( + f"DURATION PER PROJECTION: {round(duration)} seconds\t OK\n\n" + ) + else: + self.append_to_summary( + f'DURATION PER PROJECTION: {duration} seconds (should be {self.isotope_dic["planar"]["duration"]} seconds)\t MISMATCH \n\n' + ) + + # check energy windows + self.window_check_df["projections"] = self.window_check(type="raw") + + # check non-circular orbit + try: + if ( + len(set(self.proj_ds.DetectorInformationSequence[0].RadialPosition)) + > 1 + ): + self.append_to_summary("ORBIT: Non-circular\t OK\n\n") + except (AttributeError, IndexError, TypeError): + self.append_to_summary("ORBIT: Circular\t VERIFY\n\n") + + # check zoom + if zoom == self.isotope_dic["spect"]["zoom"]: + self.append_to_summary(f"ZOOM: {zoom}\t OK\n\n") + else: + self.append_to_summary( + f'ZOOM: {zoom} (should be {self.isotope_dic["planar"]["zoom"]}\t MISMATCH\n\n' + ) + + # check matrix size + if [rows, cols] == self.isotope_dic["raw"]["matrix"]: + self.append_to_summary(f"MATRIX SIZE: {rows} x {cols}\t OK\n\n") + else: + self.append_to_summary( + f'MATRIX SIZE: {rows} x {cols} (should be {self.isotope_dic["spect"]["matrix"][0]} x {self.isotope_dic["spect"]["matrix"][1]}\tMISMATCH\n\n' + ) + + # check detector motion + if ds.TypeOfDetectorMotion == self.isotope_dic["raw"]["detector_motion"]: + self.append_to_summary( + f"DETECTOR MOTION: {self.isotope_dic['raw']['detector_motion']}\t OK\n\n" + ) + else: + self.append_to_summary( + f"DETECTOR MOTION: {ds.TypeOfDetectorMotion} (should be {self.isotope_dic['raw']['detector_motion']})\t MISMATCH \n\n" + ) + + else: + self.append_to_summary(" \t \n\n") + self.append_to_summary("\nRECONSTRUCTED IMAGE:\t \n") + + # check energy windows + self.window_check_df["reconstructed_image"] = self.window_check( + type="spect" + ) + + # check applied corrections + try: + if set(self.isotope_dic["spect"]["corrections"]).issubset( + set(list(ds.CorrectedImage.split())).intersection( + set(self.isotope_dic["spect"]["corrections"]) + ) + ): + self.append_to_summary( + f"CORRECTIONS APPLIED: {self.isotope_dic['spect']['corrections']}\t OK\n\n" + ) + else: + self.append_to_summary( + f"CORRECTIONS APPLIED: {ds.CorrectedImage}. This image is not quantitative. Is missing {set(self.isotope_dic['spect']['corrections']) - set(list(ds.CorrectedImage))} correction.\t FAIL\n\n" + ) + except AttributeError: + if set(self.isotope_dic["spect"]["corrections"]).issubset( + set(list(ds.CorrectedImage)).intersection( + set(self.isotope_dic["spect"]["corrections"]) + ) + ): + self.append_to_summary( + f"CORRECTIONS APPLIED: {self.isotope_dic['spect']['corrections']}\t OK\n\n" + ) + else: + self.append_to_summary( + f"CORRECTIONS APPLIED: {ds.CorrectedImage}. This image is not quantitative. Is missing {set(self.isotope_dic['spect']['corrections']) - set(list(ds.CorrectedImage))} correction.\t FAIL\n\n" + ) + + # check matrix size + if [rows, cols] == self.isotope_dic["spect"]["matrix"]: + self.append_to_summary(f"MATRIX SIZE: {rows} x {cols}\t OK\n\n") + else: + self.append_to_summary( + f'MATRIX SIZE: {rows} x {cols} (should be {self.isotope_dic["spect"]["matrix"][0]} x {self.isotope_dic["spect"]["matrix"][1]}\tMISMATCH\n\n' + ) + + # check zoom + if zoom == self.isotope_dic["spect"]["zoom"]: + self.append_to_summary(f"ZOOM: {zoom}\t OK\n\n") + else: + self.append_to_summary( + f'ZOOM: {zoom} (should be {self.isotope_dic["planar"]["zoom"]}\tMISMATCH\n\n' + ) diff --git a/pytheranostics/registration/__init__.py b/pytheranostics/registration/__init__.py new file mode 100644 index 0000000..e47521b --- /dev/null +++ b/pytheranostics/registration/__init__.py @@ -0,0 +1,9 @@ +"""Registration package. + +PEP 8 compliant package with lowercase module names. +""" + +__all__ = [ + "ct_to_spect", + "phantom_to_ct", +] diff --git a/pytheranostics/registration/ct_to_spect.py b/pytheranostics/registration/ct_to_spect.py new file mode 100644 index 0000000..f1f3be0 --- /dev/null +++ b/pytheranostics/registration/ct_to_spect.py @@ -0,0 +1,105 @@ +"""CT to SPECT registration module. + +This module provides functions for registering CT images to SPECT images +and transforming CT-derived masks into SPECT space. +""" + +from typing import Tuple + +import SimpleITK + + +def register_ct_to_spect( + ct_image: SimpleITK.Image, spect_image: SimpleITK.Image +) -> Tuple[SimpleITK.Image, SimpleITK.Transform]: + """Register a CT image to a SPECT image using rigid registration. + + Parameters + ---------- + ct_image : SimpleITK.Image + The moving image (CT) to be registered. + spect_image : SimpleITK.Image + The fixed image (SPECT) to which CT will be registered. + + Returns + ------- + registered_ct : SimpleITK.Image + The CT image resampled in SPECT space. + final_transform : SimpleITK.Transform + The transform mapping CT space to SPECT space. + """ + # Initialize registration method + registration_method = SimpleITK.ImageRegistrationMethod() + + # Similarity metric settings + registration_method.SetMetricAsMattesMutualInformation(numberOfHistogramBins=50) + registration_method.MetricUseFixedImageGradientFilterOff() + registration_method.MetricUseMovingImageGradientFilterOff() + + # Optimizer settings + registration_method.SetOptimizerAsRegularStepGradientDescent( + learningRate=2.0, + minStep=1e-4, + numberOfIterations=200, + gradientMagnitudeTolerance=1e-8, + ) + registration_method.SetOptimizerScalesFromPhysicalShift() + + # Interpolator + registration_method.SetInterpolator(SimpleITK.sitkLinear) + + # Setup initial transform (rigid) + initial_transform = SimpleITK.CenteredTransformInitializer( + spect_image, + ct_image, + SimpleITK.Euler3DTransform(), + SimpleITK.CenteredTransformInitializerFilter.GEOMETRY, + ) + registration_method.SetInitialTransform(initial_transform, inPlace=False) + + # Multi-resolution framework + registration_method.SetShrinkFactorsPerLevel(shrinkFactors=[4, 2, 1]) + registration_method.SetSmoothingSigmasPerLevel(smoothingSigmas=[2, 1, 0]) + registration_method.SmoothingSigmasAreSpecifiedInPhysicalUnitsOn() + + # Execute registration + final_transform = registration_method.Execute( + SimpleITK.Cast(spect_image, SimpleITK.sitkFloat32), + SimpleITK.Cast(ct_image, SimpleITK.sitkFloat32), + ) + + # Resample CT into SPECT space + registered_ct = SimpleITK.Resample( + ct_image, + spect_image, + final_transform, + SimpleITK.sitkLinear, + 0.0, + ct_image.GetPixelID(), + ) + + return registered_ct, final_transform + + +def transform_ct_mask_to_spect( + mask: SimpleITK.Image, spect: SimpleITK.Image, transform: SimpleITK.Transform +) -> SimpleITK.Image: + """Transform a CT mask into SPECT space using a given transformation. + + Parameters + ---------- + mask : SimpleITK.Image + Binary mask from CT to be transformed. + spect : SimpleITK.Image + Reference SPECT image defining the target space. + transform : SimpleITK.Transform + The transformation from CT to SPECT space. + + Returns + ------- + SimpleITK.Image + The mask resampled in SPECT space. + """ + return SimpleITK.Resample( + mask, spect, transform, SimpleITK.sitkNearestNeighbor, 0, mask.GetPixelID() + ) diff --git a/pytheranostics/registration/demons.py b/pytheranostics/registration/demons.py new file mode 100644 index 0000000..f7295c1 --- /dev/null +++ b/pytheranostics/registration/demons.py @@ -0,0 +1,89 @@ +"""Multiscale Demons registration helpers.""" + +import SimpleITK + + +def smooth_and_resample(image, shrink_factor, smoothing_sigma): + """Gaussian smooth an image and resample it by the given shrink factor.""" + smoothed_image = SimpleITK.SmoothingRecursiveGaussian(image, smoothing_sigma) + + original_spacing = image.GetSpacing() + original_size = image.GetSize() + new_size = [int(sz / float(shrink_factor) + 0.5) for sz in original_size] + new_spacing = [ + ((original_sz - 1) * original_spc) / (new_sz - 1) + for original_sz, original_spc, new_sz in zip( + original_size, original_spacing, new_size + ) + ] + return SimpleITK.Resample( + smoothed_image, + new_size, + SimpleITK.Transform(), + SimpleITK.sitkLinear, + image.GetOrigin(), + new_spacing, + image.GetDirection(), + 0.0, + image.GetPixelID(), + ) + + +def multiscale_demons( + registration_algorithm, + fixed_image, + moving_image, + initial_transform=None, + shrink_factors=None, + smoothing_sigmas=None, +): + """Run a multiscale Demons registration on the provided fixed/moving pair.""" + # Create image pyramid. + fixed_images = [fixed_image] + moving_images = [moving_image] + if shrink_factors: + for shrink_factor, smoothing_sigma in reversed( + list(zip(shrink_factors, smoothing_sigmas)) + ): + fixed_images.append( + smooth_and_resample(fixed_images[0], shrink_factor, smoothing_sigma) + ) + moving_images.append( + smooth_and_resample(moving_images[0], shrink_factor, smoothing_sigma) + ) + + # Create initial displacement field at lowest resolution. + # Currently, the pixel type is required to be sitkVectorFloat64 because of a constraint imposed by the Demons filters. + if initial_transform: + initial_displacement_field = SimpleITK.TransformToDisplacementField( + initial_transform, + SimpleITK.sitkVectorFloat64, + fixed_images[-1].GetSize(), + fixed_images[-1].GetOrigin(), + fixed_images[-1].GetSpacing(), + fixed_images[-1].GetDirection(), + ) + else: + initial_displacement_field = SimpleITK.Image( + fixed_images[-1].GetWidth(), + fixed_images[-1].GetHeight(), + fixed_images[-1].GetDepth(), + SimpleITK.sitkVectorFloat64, + ) + initial_displacement_field.CopyInformation(fixed_images[-1]) + + # Run the registration. + initial_displacement_field = registration_algorithm.Execute( + fixed_images[-1], moving_images[-1], initial_displacement_field + ) + # Start at the top of the pyramid and work our way down. + for f_image, m_image in reversed( + list(zip(fixed_images[0:-1], moving_images[0:-1])) + ): + initial_displacement_field = SimpleITK.Resample( + initial_displacement_field, f_image + ) + initial_displacement_field = registration_algorithm.Execute( + f_image, m_image, initial_displacement_field + ) + return SimpleITK.DisplacementFieldTransform(initial_displacement_field) diff --git a/pytheranostics/registration/phantom_to_ct.py b/pytheranostics/registration/phantom_to_ct.py new file mode 100644 index 0000000..7766fb6 --- /dev/null +++ b/pytheranostics/registration/phantom_to_ct.py @@ -0,0 +1,304 @@ +"""Phantom to CT registration module. + +This module provides registration of XCAT phantom anatomy to patient CT scans, +primarily for bone marrow segmentation. +""" + +from pathlib import Path +from typing import Optional + +import SimpleITK +from SimpleITK.SimpleITK import Transform + +from pytheranostics.registration.demons import multiscale_demons +from pytheranostics.shared.resources import resource_path + + +class PhantomToCTBoneReg: + """ + Register an XCAT Phantom to a patient CT. Following SimpleITK-Notebooks repository. + + Parameters + ---------- + CT_dir: + + phantom_dir: Path to directory containing Phantom DICOM data. + Phantom Data represents the bone+Marrow anatomy of an standard Male XCAT Phantom. + + Limitations: + For optimal performance, CT and Phantom should roughly cover the same + FOV, and be oriented along the same direction. + """ + + def __init__( + self, + CT: SimpleITK.Image, + phantom_skeleton_path: Optional[Path] = None, + verbose: bool = False, + ) -> None: + """Initialize the Phantom-to-CT registration helper. + + Parameters + ---------- + CT : SimpleITK.Image + The reference patient CT image. + phantom_skeleton_path : Path, optional + Path to the XCAT phantom skeleton image (NIfTI). If omitted, the + bundled phantom skeleton distributed with PyTheranostics is used. + verbose : bool, optional + Enable verbose logging during registration, by default False. + """ + self.CT = SimpleITK.Image(CT) # Make a Copy. + if phantom_skeleton_path is None: + with resource_path( + "pytheranostics.data", "phantom/skeleton/Skeleton.nii.gz" + ) as skeleton_path: + self.Phantom = SimpleITK.ReadImage(fileName=str(skeleton_path)) + else: + self.Phantom = SimpleITK.ReadImage(fileName=str(phantom_skeleton_path)) + + # Set Origin for Phantom to that of reference CT. + self.Phantom.SetOrigin(self.CT.GetOrigin()) + + # Threshold phantom and CT to get bone anatomy. + self.Phantom = SimpleITK.Cast(self.Phantom > 0, SimpleITK.sitkFloat32) + self.CT = SimpleITK.Cast(self.CT > 100, SimpleITK.sitkFloat32) + + # Instance of Rigid and Elastic Registration Algorithms + self.InitialTransform: Optional[Transform] = None + self.RigidTransform: Optional[Transform] = None + self.ElasticTransform: Optional[Transform] = None + + self.verbose = verbose + + def initial_alignment( + self, fixed_image: SimpleITK.Image, moving_image: SimpleITK.Image + ) -> None: + """Perform initial geometric alignment of images. + + Parameters + ---------- + fixed_image : SimpleITK.Image + The reference CT image. + moving_image : SimpleITK.Image + The phantom image to be aligned. + """ + self.InitialTransform = SimpleITK.CenteredTransformInitializer( + fixed_image, + moving_image, + SimpleITK.Euler3DTransform(), + SimpleITK.CenteredTransformInitializerFilter.GEOMETRY, + ) + + return None + + def rigid_alignment( + self, fixed_image: SimpleITK.Image, moving_image: SimpleITK.Image + ) -> None: + """Perform rigid registration between images. + + Note: Currently only supports default parameters. + TODO: add support for user-defined registration parameters, probably using **kwargs + + Parameters + ---------- + fixed_image : SimpleITK.Image + The reference CT image. + moving_image : SimpleITK.Image + The phantom image to be registered. + """ + registration_method = ( + SimpleITK.ImageRegistrationMethod() + ) # Similarity metric settings. + registration_method.SetMetricAsMattesMutualInformation(numberOfHistogramBins=50) + registration_method.SetMetricSamplingStrategy(registration_method.RANDOM) + registration_method.SetMetricSamplingPercentage(0.01) + + registration_method.SetInterpolator(SimpleITK.sitkLinear) + + # Optimizer settings. + registration_method.SetOptimizerAsGradientDescent( + learningRate=1.0, + numberOfIterations=100, + convergenceMinimumValue=1e-6, + convergenceWindowSize=10, + ) + registration_method.SetOptimizerScalesFromPhysicalShift() + + # Setup for the multi-resolution framework. + registration_method.SetShrinkFactorsPerLevel(shrinkFactors=[4, 2, 1]) + registration_method.SetSmoothingSigmasPerLevel(smoothingSigmas=[2, 1, 0]) + registration_method.SmoothingSigmasAreSpecifiedInPhysicalUnitsOn() + + # Don't optimize in-place + if self.InitialTransform is None: + raise AssertionError("Initial Transform was not applied.") + + registration_method.SetInitialTransform(self.InitialTransform, inPlace=False) + self.RigidTransform = registration_method.Execute( + SimpleITK.Cast(fixed_image, SimpleITK.sitkFloat32), + SimpleITK.Cast(moving_image, SimpleITK.sitkFloat32), + ) + + return None + + def elastic_alignment( + self, fixed_image: SimpleITK.Image, moving_image: SimpleITK.Image + ) -> None: + """Perform elastic (deformable) registration using demons algorithm. + + Parameters + ---------- + fixed_image : SimpleITK.Image + The reference CT image. + moving_image : SimpleITK.Image + The phantom image to be registered. + """ + + def iteration_callback(filter): # TODO: Remove verbose ... or make it optional. + # Define a simple callback which allows us to monitor registration progress. + if self.verbose: + print( + f"Iteration: {filter.GetElapsedIterations()}, Metric: {filter.GetMetric()}\n" + ) + + # Select a Demons filter and configure it. + demons_filter = SimpleITK.FastSymmetricForcesDemonsRegistrationFilter() + demons_filter.SetNumberOfIterations(150) + + # Regularization (update field - viscous, total field - elastic). + demons_filter.SetSmoothDisplacementField(True) + demons_filter.SetStandardDeviations(2.0) + + # Add our simple callback to the registration filter. + demons_filter.AddCommand( + SimpleITK.sitkIterationEvent, lambda: iteration_callback(demons_filter) + ) + + # Run the registration. + self.ElasticTransform = multiscale_demons( + registration_algorithm=demons_filter, + fixed_image=fixed_image, + moving_image=moving_image, + shrink_factors=[4, 2, 1], + smoothing_sigmas=[8, 4, 2], + ) + + return None + + @staticmethod + def transform( + fixed_image: SimpleITK.Image, + moving_image: SimpleITK.Image, + transform: Transform, + ) -> SimpleITK.Image: + """Apply a transformation to resample moving image into fixed image space. + + Parameters + ---------- + fixed_image : SimpleITK.Image + The reference image defining the target space. + moving_image : SimpleITK.Image + The image to be transformed. + transform : Transform + The transformation to apply. + + Returns + ------- + SimpleITK.Image + The transformed image in fixed image space. + """ + if transform is None: + raise AssertionError("Transform was not calculated.") + + return SimpleITK.Resample( + moving_image, + fixed_image, + transform, + SimpleITK.sitkLinear, + 0.0, + moving_image.GetPixelID(), + ) + + def register( + self, fixed_image: SimpleITK.Image, moving_image: SimpleITK.Image + ) -> SimpleITK.Image: + """Perform full registration pipeline: initial alignment, rigid, and elastic. + + Parameters + ---------- + fixed_image : SimpleITK.Image + The reference CT image. + moving_image : SimpleITK.Image + The phantom image to be registered. + + Returns + ------- + SimpleITK.Image + The registered phantom image in CT space. + """ + # Initial Geometric Transform: Align images in space. + self.initial_alignment(fixed_image=fixed_image, moving_image=moving_image) + + assert self.InitialTransform is not None + moving_image = self.transform( + fixed_image=fixed_image, + moving_image=moving_image, + transform=self.InitialTransform, + ) + + # Rigid Registration + if self.RigidTransform is None: + print("Computing Rigid Registration ...") + self.rigid_alignment(fixed_image=fixed_image, moving_image=moving_image) + + assert self.RigidTransform is not None + moving_image = self.transform( + fixed_image=fixed_image, + moving_image=moving_image, + transform=self.RigidTransform, + ) + + # Elastic Registration + if self.ElasticTransform is None: + print("Computing Elastic Registration, This might take several minutes ...") + self.elastic_alignment(fixed_image=fixed_image, moving_image=moving_image) + + assert self.ElasticTransform is not None + return self.transform( + fixed_image=fixed_image, + moving_image=moving_image, + transform=self.ElasticTransform, + ) + + def register_mask( + self, + fixed_image: SimpleITK.Image, + mask_path: Optional[Path] = None, + ) -> SimpleITK.Image: + """Register a phantom mask (e.g., bone marrow) to patient CT. + + Parameters + ---------- + fixed_image : SimpleITK.Image + The reference CT image. + mask_path : Path, optional + Path to the phantom mask file. If omitted, the packaged bone marrow + mask is used. + + Returns + ------- + SimpleITK.Image + The registered mask in CT space. + """ + if mask_path is None: + with resource_path( + "pytheranostics.data", "phantom/bone_marrow/Marrow.nii.gz" + ) as default_mask: + mask_image = SimpleITK.ReadImage(fileName=str(default_mask)) + else: + mask_image = SimpleITK.ReadImage(fileName=str(mask_path)) + mask_image.SetOrigin(fixed_image.GetOrigin()) + mask_image = SimpleITK.Cast(mask_image, SimpleITK.sitkFloat32) + + return self.register(fixed_image=fixed_image, moving_image=mask_image) diff --git a/pytheranostics/segmentation/__init__.py b/pytheranostics/segmentation/__init__.py new file mode 100644 index 0000000..3731650 --- /dev/null +++ b/pytheranostics/segmentation/__init__.py @@ -0,0 +1 @@ +"""PyTheranostics package.""" diff --git a/doodle/segmentation/tools.py b/pytheranostics/segmentation/tools.py similarity index 68% rename from doodle/segmentation/tools.py rename to pytheranostics/segmentation/tools.py index 9e7a2cb..f18b29b 100644 --- a/doodle/segmentation/tools.py +++ b/pytheranostics/segmentation/tools.py @@ -1,11 +1,13 @@ +"""Helpers for working with RT structure sets.""" + from rt_utils import RTStructBuilder -import matplotlib.pyplot as plt -def rtst_to_mask(dicom_series_path,rt_struct_path): + +def rtst_to_mask(dicom_series_path, rt_struct_path): + """Load an RTSTRUCT and return a dict of ROI masks keyed by ROI name.""" # Load existing RT Struct. Requires the series path and existing RT Struct path rtstruct = RTStructBuilder.create_from( - dicom_series_path=dicom_series_path, - rt_struct_path=rt_struct_path + dicom_series_path=dicom_series_path, rt_struct_path=rt_struct_path ) # View all of the ROI names from within the image @@ -22,4 +24,4 @@ def rtst_to_mask(dicom_series_path,rt_struct_path): # # Display one slice of the region # first_mask_slice = mask_3d[voi][:, :, 0] # plt.imshow(first_mask_slice) - # plt.show() \ No newline at end of file + # plt.show() diff --git a/pytheranostics/shared/__init__.py b/pytheranostics/shared/__init__.py new file mode 100644 index 0000000..3731650 --- /dev/null +++ b/pytheranostics/shared/__init__.py @@ -0,0 +1 @@ +"""PyTheranostics package.""" diff --git a/pytheranostics/shared/corrections.py b/pytheranostics/shared/corrections.py new file mode 100644 index 0000000..28b98f7 --- /dev/null +++ b/pytheranostics/shared/corrections.py @@ -0,0 +1,27 @@ +"""Scatter-correction helpers.""" + + +def tew_scatt(window_dic): + """Apply triple-energy-window scatter correction to window counts.""" + Cp = {} + + ls_width = window_dic["low_scatter"]["width"] + us_width = window_dic["upper_scatter"]["width"] + pp_width = window_dic["photopeak"]["width"] + + for det in window_dic["photopeak"]["counts"]: + + Cs = ( + ( + window_dic["low_scatter"]["counts"][det] / ls_width + + window_dic["upper_scatter"]["counts"][det] / us_width + ) + * pp_width + / 2 + ) + Cpw = window_dic["photopeak"]["counts"][det] + + Cp[det] = Cpw - Cs + + # return the primary counts after scatter correction + return Cp diff --git a/pytheranostics/shared/evaluation_metrics.py b/pytheranostics/shared/evaluation_metrics.py new file mode 100644 index 0000000..4fd7b58 --- /dev/null +++ b/pytheranostics/shared/evaluation_metrics.py @@ -0,0 +1,8 @@ +"""Simple evaluation metrics used in QC reports.""" + +import numpy as np + + +def perc_diff(measured_value, expected_value, decimals=2): + """Return the percent difference between measured and expected values.""" + return np.round((measured_value - expected_value) / expected_value * 100, decimals) diff --git a/pytheranostics/shared/radioactive_decay.py b/pytheranostics/shared/radioactive_decay.py new file mode 100644 index 0000000..d259ec8 --- /dev/null +++ b/pytheranostics/shared/radioactive_decay.py @@ -0,0 +1,51 @@ +"""Radioactive decay helpers shared across modules.""" + +from datetime import datetime + +import numpy as np + + +def decay_act(a_initial, delta_t, half_life): + """Return decayed activity after `delta_t` given the half-life.""" + if np.any(np.asarray(a_initial) < 0): + raise ValueError("a_initial must be positive") + if np.any(np.asarray(delta_t) < 0): + raise ValueError("delta_t must be positive") + if np.any(np.asarray(half_life) < 0): + raise ValueError("half_life must be positive") + + return a_initial * np.exp(-np.log(2) / half_life * delta_t) + + +def get_activity_at_injection( + injection_date, + pre_inj_activity, + pre_inj_time, + post_inj_activity, + post_inj_time, + injection_time, + half_life, +): + """Compute injection datetime and activity from pre/post syringe readings.""" + # Pass half-life in seconds + + # Set the times and the time deltas to injection time + pre_datetime = datetime.strptime( + injection_date + pre_inj_time + "00.00", "%Y%m%d%H%M%S.%f" + ) + post_datetime = datetime.strptime( + injection_date + post_inj_time + "00.00", "%Y%m%d%H%M%S.%f" + ) + inj_datetime = datetime.strptime( + injection_date + injection_time + "00.00", "%Y%m%d%H%M%S.%f" + ) + + delta_inj_pre = (inj_datetime - pre_datetime).total_seconds() + delta_post_inj = (inj_datetime - post_datetime).total_seconds() + + pre_activity = decay_act(pre_inj_activity, delta_inj_pre, half_life) + post_activity = decay_act(post_inj_activity, delta_post_inj, half_life) + + injected_activity = pre_activity - post_activity + + return inj_datetime, injected_activity diff --git a/pytheranostics/shared/resources.py b/pytheranostics/shared/resources.py new file mode 100644 index 0000000..0f37ea5 --- /dev/null +++ b/pytheranostics/shared/resources.py @@ -0,0 +1,22 @@ +"""Utility helpers for accessing package data via importlib.resources.""" + +from __future__ import annotations + +from contextlib import contextmanager +from importlib import resources +from pathlib import Path +from typing import Iterator + + +@contextmanager +def resource_path(package: str, relative_path: str) -> Iterator[Path]: + """Yield a filesystem path to a bundled resource. + + The helper works for both files and directories and hides the boilerplate + of using ``importlib.resources.as_file``. It ensures compatibility when the + package is installed as a wheel/zip where resources need to be extracted to + a temporary location before accessing them by path. + """ + resource = resources.files(package).joinpath(*relative_path.split("/")) + with resources.as_file(resource) as path_obj: + yield Path(path_obj) diff --git a/pytheranostics/shared/types.py b/pytheranostics/shared/types.py new file mode 100644 index 0000000..eb3b231 --- /dev/null +++ b/pytheranostics/shared/types.py @@ -0,0 +1,27 @@ +"""Shared type definitions for pytheranostics. + +This module contains lightweight, dependency-free data structures that are +shared across subpackages to avoid circular imports. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class ImagingMetadata: + """Metadata information for medical imaging datasets. + + This dataclass is intentionally defined in a shared, neutral module so it + can be imported by both imaging_tools and imaging_ds without creating + circular dependencies. + """ + + PatientID: str + AcquisitionDate: str + AcquisitionTime: str + HoursAfterInjection: Optional[float] + Radionuclide: Optional[str] + Injected_Activity_MBq: Optional[float] diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..65b2f54 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,21 @@ +# Flake8 configuration +# Note: Flake8 does NOT support pyproject.toml natively (as of 2025) +# See: https://github.com/PyCQA/flake8/issues/234 +[flake8] +max-line-length = 88 +# Ignore formatting issues that Black handles differently: +# E203: whitespace before ':' (Black handles this) +# E501: line too long (Black handles this) +# E226: missing whitespace around arithmetic operator (Black is more selective) +# W503: line break before binary operator (Black prefers this style) +extend-ignore = E203,W503,E501,E226 +exclude = + .git, + __pycache__, + .pytest_cache, + .mypy_cache, + venv, + .venv, + build, + dist, + *.egg-info diff --git a/setup.py b/setup.py deleted file mode 100644 index 3a493f7..0000000 --- a/setup.py +++ /dev/null @@ -1,15 +0,0 @@ -from setuptools import setup - -setup(name='DOODLE', - version='0.1', - description='3D dOsimetry fOr raDiopharmaceuticaL thErapies', - url='https://github.com/carluri/doodle.git', - author='Carlos Uribe', - author_email='curibe@bccrc.ca', - license='LICENSE', - packages=['doodle','doodle.tests'], - include_package_data=True, - install_requires = [ - 'numpy','matplotlib','pandas = 1.5.3','pydicom','openpyxl','rt_utils','scikit-image' - ], - zip_safe=False) \ No newline at end of file diff --git a/setup_dev.py b/setup_dev.py new file mode 100644 index 0000000..d9333b2 --- /dev/null +++ b/setup_dev.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Development environment setup script. + +Activate a virtual environment or conda environment before running. +""" + +import os +import subprocess +import sys + + +def is_virtual_env(): + """Check if we're running in a virtual environment or conda environment.""" + return ( + hasattr(sys, "real_prefix") # virtualenv + or (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix) # venv + or "VIRTUAL_ENV" in os.environ # venv environment variable + or "CONDA_DEFAULT_ENV" in os.environ # conda environment + or "CONDA_PREFIX" in os.environ # conda environment + ) + + +def main(): + """Set up the development environment.""" + print("PyTheranostics Development Setup") + print("=" * 35) + + # Check if we're in a virtual environment + if not is_virtual_env(): + print("❌ ERROR: Not running in a virtual or conda environment!") + print("\nPlease activate your environment first:") + print(" Virtual env (Windows): .venv\\Scripts\\activate") + print(" Virtual env (Linux/Mac): source .venv/bin/activate") + print(" Conda: conda activate ") + sys.exit(1) + + # Detect environment type + env_type = "conda" if "CONDA_DEFAULT_ENV" in os.environ else "virtual" + env_name = os.environ.get("CONDA_DEFAULT_ENV", "virtual environment") + + print(f"✅ {env_type.capitalize()} environment detected: {env_name}") + print(f"Python executable: {sys.executable}") + + # Upgrade pip first + print("\n📦 Upgrading pip...") + subprocess.run( + [sys.executable, "-m", "pip", "install", "--upgrade", "pip"], check=True + ) + + # Install package in editable mode with dev dependencies + print("\n🔧 Installing PyTheranostics in editable mode with dev dependencies...") + subprocess.run([sys.executable, "-m", "pip", "install", "-e", ".[dev]"], check=True) + + # Install and setup pre-commit hooks + print("\n🪝 Installing pre-commit...") + subprocess.run([sys.executable, "-m", "pip", "install", "pre-commit"], check=True) + + print("🪝 Setting up pre-commit hooks...") + subprocess.run(["pre-commit", "install"], check=True) + + # Install missing mypy stubs (optional - requires internet) + print("\n🎯 Installing missing mypy type stubs...") + try: + subprocess.run(["mypy", "--install-types", "--non-interactive"], check=True) + print("✅ Type stubs installed successfully") + except subprocess.CalledProcessError: + print("⚠️ Could not install some type stubs (this is usually OK)") + + print("\n✅ Development environment setup complete!") + print("\nYou can now run:") + print(" pytest # Run tests") + print(" pytest -m smoke # Run smoke tests only") + print(" black . # Format code") + print(" flake8 # Lint code") + print(" pydocstyle pytheranostics # Check docstring style (NumPy format)") + print(" mypy pytheranostics # Type check") + print(" pre-commit run --all-files # Run all pre-commit checks") + print("\n🪝 Pre-commit hooks are now active and will run on every commit") + print(" To disable pre-commit hooks, run:") + print(" pre-commit uninstall") + + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2406a63 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,54 @@ +"""Test configuration and fixtures for PyTheranostics.""" + +from pathlib import Path + +import numpy as np +import pytest + + +def pytest_collection_modifyitems(config, items): + """Modify test collection to run smoke tests first.""" + # Separate smoke tests from other tests + smoke_tests = [] + other_tests = [] + + for item in items: + if "smoke" in item.keywords: + smoke_tests.append(item) + else: + other_tests.append(item) + + # Reorder: smoke tests first, then others + items[:] = smoke_tests + other_tests + + +@pytest.fixture +def sample_image(): + """Create a sample image for testing.""" + return np.random.rand(100, 100) + + +@pytest.fixture +def sample_activity(): + """Create sample activity data.""" + return np.array([1000, 800, 600, 400, 200]) + + +@pytest.fixture +def sample_time_points(): + """Create sample time points.""" + return np.array([0, 1, 2, 3, 4]) + + +@pytest.fixture(scope="session") +def docs_examples_dir() -> Path: + """Return the path to the documentation example data directory.""" + return ( + Path(__file__).resolve().parent.parent / "docs" / "source" / "examples" / "data" + ) + + +@pytest.fixture(scope="session") +def spect_example_dir(docs_examples_dir: Path) -> Path: + """Directory containing sample SPECT DICOM images.""" + return docs_examples_dir / "testimages" diff --git a/tests/test_dosimetry_bone_marrow.py b/tests/test_dosimetry_bone_marrow.py new file mode 100644 index 0000000..8895aa9 --- /dev/null +++ b/tests/test_dosimetry_bone_marrow.py @@ -0,0 +1,26 @@ +"""Unit tests for bone marrow dosimetry helpers.""" + +import math + +import pytest + +from pytheranostics.dosimetry.bone_marrow import bm_scaling_factor + + +def test_bm_scaling_factor_uses_phantom_mass_by_default(): + """If no patient mass is provided, phantom data should be used.""" + result = bm_scaling_factor(gender="Female", mass_bm=None, hematocrit=None) + assert math.isclose(result, 900.0, rel_tol=1e-6) + + +@pytest.mark.parametrize( + ("gender", "mass_bm", "hematocrit", "expected"), + [ + ("Male", 1000.0, None, 1000.0), + ("Female", 900.0, 0.45, 0.19 / (1 - 0.45) * 900.0), + ], +) +def test_bm_scaling_factor_handles_custom_values(gender, mass_bm, hematocrit, expected): + """The scaling factor should respect custom masses and hematocrit.""" + result = bm_scaling_factor(gender=gender, mass_bm=mass_bm, hematocrit=hematocrit) + assert math.isclose(result, expected, rel_tol=1e-6) diff --git a/tests/test_fits_module.py b/tests/test_fits_module.py new file mode 100644 index 0000000..135a383 --- /dev/null +++ b/tests/test_fits_module.py @@ -0,0 +1,60 @@ +"""Tests for the fitting helper functions.""" + +import numpy as np +import pytest + +from pytheranostics.fits.fits import ( + calculate_r_squared, + exponential_fit_lmfit, + get_exponential, +) +from pytheranostics.fits.functions import monoexp_fun + + +def test_get_exponential_defaults(): + """Default configuration for mono-exponential fits should be stable.""" + func, params, bounds = get_exponential(order=1, param_init=None, decayconst=0.1) + assert func is monoexp_fun + assert params == (1, 1) + assert bounds[0][0] == 0 + assert pytest.approx(bounds[0][1]) == 0.1 + assert np.isinf(bounds[1]) + + +def test_calculate_r_squared_perfect_fit(): + """A perfect mono-exponential fit should have r^2 == 1.""" + x = np.linspace(0, 4, 5) + y = monoexp_fun(x, 2.0, 0.5) + r2, residuals = calculate_r_squared(x, y, (2.0, 0.5), monoexp_fun) + assert pytest.approx(r2, rel=1e-9) == 1.0 + assert np.allclose(residuals, 0.0) + + +def test_exponential_fit_lmfit_mono_handles_noise(): + """Mono-exponential fit should recover parameters from noisy data.""" + rng = np.random.default_rng(42) + x = np.linspace(0, 6, 20) + y_true = monoexp_fun(x, 5.0, 0.4) + y_noisy = y_true + rng.normal(scale=0.05, size=x.shape) + + result, fitted_model = exponential_fit_lmfit( + x_data=x, y_data=y_noisy, num_exponentials=1 + ) + + assert pytest.approx(result.params["A1"].value, rel=0.05) == 5.0 + assert pytest.approx(result.params["A2"].value, rel=0.1) == 0.4 + assert np.allclose( + fitted_model(x[:3]), + monoexp_fun(x[:3], result.params["A1"].value, result.params["A2"].value), + ) + + +def test_exponential_fit_lmfit_applies_uptake_constraint(): + """Bi-exponential fits with uptake should constrain the amplitudes.""" + x = np.linspace(0, 4, 15) + y = monoexp_fun(x, 2.0, 0.5) + monoexp_fun(x, -2.0, 1.5) + + result, _ = exponential_fit_lmfit(x, y, num_exponentials=2, with_uptake=True) + + assert result.params["B1"].expr == "-A1" + assert pytest.approx(result.params["A1"].value, rel=0.1) == 2.0 diff --git a/tests/test_imaging_tools.py b/tests/test_imaging_tools.py new file mode 100644 index 0000000..e2a55c1 --- /dev/null +++ b/tests/test_imaging_tools.py @@ -0,0 +1,45 @@ +"""Tests for imaging tools utilities.""" + +import shutil + +import numpy as np +import pytest +import SimpleITK + +from pytheranostics.imaging_tools import tools + + +def test_load_metadata_from_sample_spect_folder(spect_example_dir, tmp_path): + """Ensure metadata extraction works on bundled DICOM samples.""" + single_case_dir = tmp_path / "spect_case" + single_case_dir.mkdir() + shutil.copy(spect_example_dir / "016.dcm", single_case_dir / "case.dcm") + + meta = tools.load_metadata(str(single_case_dir), modality="Lu177_NM") + assert meta.PatientID == "PR21-CAVA-0016" + assert meta.AcquisitionDate == "20220617" + # DICOM lacks injected activity tag -> default should apply + assert meta.Injected_Activity_MBq == 7400.0 + assert meta.Radionuclide == "Lu177" + + +@pytest.mark.parametrize("is_mask", [True, False]) +def test_itk_image_from_array_preserves_metadata(is_mask): + """Array conversion should preserve spacing/origin/direction.""" + ref = SimpleITK.Image(2, 2, 2, SimpleITK.sitkFloat32) + ref.SetSpacing((2.0, 2.0, 5.0)) + ref.SetOrigin((1.0, 1.0, -3.0)) + ref.SetDirection((1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0)) + ref.SetMetaData("0010|0010", "Test Patient") + + array = np.ones((2, 2, 2), dtype=np.uint8 if is_mask else np.float32) + image = tools.itk_image_from_array(array, ref_image=ref, is_mask=is_mask) + + assert tuple(image.GetSpacing()) == pytest.approx(ref.GetSpacing()) + assert tuple(image.GetOrigin()) == pytest.approx(ref.GetOrigin()) + assert tuple(image.GetDirection()) == tuple(ref.GetDirection()) + assert image.GetMetaData("0010|0010") == "Test Patient" + if is_mask: + assert image.GetPixelID() == SimpleITK.sitkUInt8 + else: + assert image.GetPixelID() == ref.GetPixelID() diff --git a/tests/test_longitudinal_study.py b/tests/test_longitudinal_study.py new file mode 100644 index 0000000..fba8834 --- /dev/null +++ b/tests/test_longitudinal_study.py @@ -0,0 +1,505 @@ +"""Test suite for LongitudinalStudy class. + +This test suite uses minimal data objects and property-based testing +to validate the LongitudinalStudy class functionality without requiring +large medical imaging datasets. +""" + +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest +import SimpleITK + +from pytheranostics.imaging_ds.longitudinal_study import LongitudinalStudy +from pytheranostics.imaging_ds.metadata import ImagingMetadata + + +class TestLongitudinalStudyFixtures: + """Test fixtures and helper methods for LongitudinalStudy testing.""" + + @staticmethod + def create_mock_sitk_image( + shape=(7, 10, 12), spacing=(1.0, 1.0, 1.0), origin=(0.0, 0.0, 0.0) + ): + """Create a minimal mock SimpleITK image for testing. + + Args: + shape: Image dimensions (x, y, z) + spacing: Voxel spacing (x, y, z) + origin: Image origin (x, y, z) + + Returns: + Mock SimpleITK.Image with minimal required functionality + """ + mock_image = MagicMock(spec=SimpleITK.Image) + mock_image.GetSpacing.return_value = spacing + mock_image.GetOrigin.return_value = origin + mock_image.GetSize.return_value = shape + mock_image.GetPixelIDValue.return_value = 1 # sitkUInt8 = 1 + + return mock_image + + @staticmethod + def create_test_metadata( + patient_id="TEST_001", + acquisition_date="20250101", + acquisition_time="120000", + hours_after_injection=24.0, + radionuclide="Lu-177", + injected_activity_mbq=7400.0, + ): + """Create test metadata with sensible defaults.""" + return ImagingMetadata( + PatientID=patient_id, + AcquisitionDate=acquisition_date, + AcquisitionTime=acquisition_time, + HoursAfterInjection=hours_after_injection, + Radionuclide=radionuclide, + Injected_Activity_MBq=injected_activity_mbq, + ) + + @staticmethod + def create_minimal_study(num_timepoints=2, modality="NM", image_shape=(6, 10, 13)): + """Create a minimal LongitudinalStudy for testing. + + Args: + num_timepoints: Number of time points to create + modality: Imaging modality + image_shape: Shape of mock images + + Returns: + LongitudinalStudy instance with mock data + """ + images = {} + meta = {} + + for time_id in range(num_timepoints): + # Create mock image + mock_image = MagicMock(spec=SimpleITK.Image) + mock_image.GetSpacing.return_value = (1.0, 1.0, 1.0) + mock_image.GetOrigin.return_value = (0.0, 0.0, 0.0) + mock_image.GetSize.return_value = image_shape + mock_image.GetPixelIDValue.return_value = 1 # sitkUInt8 = 1 + images[time_id] = mock_image + + # Create metadata + meta[time_id] = TestLongitudinalStudyFixtures.create_test_metadata( + patient_id=f"TEST_{time_id:03d}", + hours_after_injection=24.0 + time_id * 24.0, + ) + + return LongitudinalStudy(images=images, meta=meta, modality=modality) + + +class TestLongitudinalStudyInit: + """Test LongitudinalStudy initialization and validation.""" + + def test_init_success_minimal(self): + """Test successful initialization with minimal valid data.""" + study = TestLongitudinalStudyFixtures.create_minimal_study() + + assert study.modality == "NM" + assert len(study.images) == 2 + assert len(study.meta) == 2 + assert len(study.masks) == 0 + assert isinstance(study._VALID_ORGAN_NAMES, list) + assert "Liver" in study._VALID_ORGAN_NAMES + assert LongitudinalStudy._is_valid_mask_name("Lesion_1") + + def test_init_mismatched_keys_raises_error(self): + """Test that mismatched image and metadata keys raise ValueError.""" + images = {0: MagicMock(spec=SimpleITK.Image)} + meta = {1: TestLongitudinalStudyFixtures.create_test_metadata()} + + with pytest.raises( + ValueError, + match="Not all time points have corresponding images and metadata", + ): + LongitudinalStudy(images=images, meta=meta) + + @pytest.mark.parametrize("modality", ["NM", "PT", "CT", "DOSE"]) + def test_init_valid_modalities(self, modality): + """Test initialization with all valid modalities.""" + study = TestLongitudinalStudyFixtures.create_minimal_study(modality=modality) + assert study.modality == modality + + @pytest.mark.parametrize("invalid_modality", ["MRI", "US", "XR", "invalid"]) + def test_init_invalid_modality_raises_error(self, invalid_modality): + """Test that invalid modalities raise ValueError.""" + images = {0: MagicMock(spec=SimpleITK.Image)} + meta = {0: TestLongitudinalStudyFixtures.create_test_metadata()} + + with pytest.raises( + ValueError, match=f"Modality {invalid_modality} is not supported" + ): + LongitudinalStudy(images=images, meta=meta, modality=invalid_modality) + + +class TestLongitudinalStudyArrayAccess: + """Test array access methods with mock data.""" + + @patch("SimpleITK.GetArrayFromImage") + def test_array_at_success(self, mock_get_array): + """Test successful array access.""" + # Setup mock return value + test_array = np.random.rand(7, 10, 12) + mock_get_array.return_value = test_array + + study = TestLongitudinalStudyFixtures.create_minimal_study() + + result = study.array_at(time_id=0) + + # Verify the array is transposed correctly + expected_shape = (test_array.shape[1], test_array.shape[2], test_array.shape[0]) + assert result.shape == expected_shape + mock_get_array.assert_called_once() + + def test_array_at_invalid_time_id(self): + """Test array access with invalid time_id.""" + study = TestLongitudinalStudyFixtures.create_minimal_study() + + with pytest.raises(KeyError): + study.array_at(time_id=999) + + +class TestLongitudinalStudyActivityCalculations: + """Test activity-related calculations.""" + + @patch("SimpleITK.GetArrayFromImage") + def test_array_of_activity_at_invalid_modality(self, mock_get_array): + """Test that activity calculation fails for non-nuclear modalities.""" + study = TestLongitudinalStudyFixtures.create_minimal_study(modality="CT") + + with pytest.raises( + ValueError, match="Activity can't be calculated from CT data" + ): + study.array_of_activity_at(time_id=0) + + @patch("SimpleITK.GetArrayFromImage") + def test_array_of_activity_at_invalid_time_id(self, mock_get_array): + """Test activity calculation with invalid time_id.""" + study = TestLongitudinalStudyFixtures.create_minimal_study(modality="NM") + + with pytest.raises(ValueError, match="Time ID 999 not found in dataset"): + study.array_of_activity_at(time_id=999) + + @patch("SimpleITK.GetArrayFromImage") + def test_array_of_activity_at_no_region_creates_ones_mask(self, mock_get_array): + """Test that activity calculation without region creates ones mask.""" + test_array = np.random.rand(5, 5, 5) + mock_get_array.return_value = test_array + + study = TestLongitudinalStudyFixtures.create_minimal_study(modality="NM") + + # Mock voxel_volume to return a simple value + study.voxel_volume = MagicMock(return_value=1.0) + + result = study.array_of_activity_at(time_id=0, region=None) + + # Should equal array * 1.0 (ones mask) * 1.0 (voxel volume) + # Note: array will be transposed, so we need to account for that + expected_shape = (test_array.shape[1], test_array.shape[2], test_array.shape[0]) + assert result.shape == expected_shape + + @patch("SimpleITK.GetArrayFromImage") + def test_array_of_activity_at_region_no_masks_raises_error(self, mock_get_array): + """Test that requesting region without masks raises appropriate error.""" + study = TestLongitudinalStudyFixtures.create_minimal_study(modality="NM") + + # Set up mock to return a realistic array + mock_get_array.return_value = np.random.rand(10, 10, 10) + + with pytest.raises(ValueError, match="Time ID 0 does not include mask data"): + study.array_of_activity_at(time_id=0, region="Liver") + + @patch("SimpleITK.GetArrayFromImage") + def test_array_of_activity_at_invalid_region_raises_error(self, mock_get_array): + """Test that requesting invalid region raises appropriate error.""" + study = TestLongitudinalStudyFixtures.create_minimal_study(modality="NM") + + # Set up mock to return a realistic array + mock_get_array.return_value = np.random.rand(10, 10, 10) + + # Add empty masks dictionary for time_id 0 + study.masks[0] = {"Liver": np.ones((10, 10, 10), dtype=np.bool_)} + + with pytest.raises(ValueError, match="Region InvalidRegion not found"): + study.array_of_activity_at(time_id=0, region="InvalidRegion") + + +class TestLongitudinalStudyMaskManagement: + """Test mask addition and validation.""" + + def test_add_masks_to_time_point_basic_success(self): + """Test basic mask addition functionality.""" + study = TestLongitudinalStudyFixtures.create_minimal_study() + + # Create mock mask image + mock_mask_image = MagicMock(spec=SimpleITK.Image) + masks = {"liver_mask": mock_mask_image} + mask_mapping = {"liver_mask": "Liver"} + + # Mock the required functions + with patch( + "pytheranostics.imaging_ds.longitudinal_study.resample_mask_to_target" + ) as mock_resample, patch("SimpleITK.GetArrayFromImage") as mock_get_array: + + mock_resample.return_value = mock_mask_image + mock_get_array.return_value = np.ones((10, 10, 10), dtype=np.uint8) + + study.add_masks_to_time_point( + time_id=0, masks=masks, mask_mapping=mask_mapping + ) + + assert 0 in study.masks + assert "Liver" in study.masks[0] + assert study.masks[0]["Liver"].dtype == np.bool_ + + def test_add_masks_invalid_source_mask_raises_error(self): + """Test that invalid source mask name raises error.""" + study = TestLongitudinalStudyFixtures.create_minimal_study() + + masks = {"existing_mask": MagicMock(spec=SimpleITK.Image)} + mask_mapping = {"nonexistent_mask": "Liver"} + + with pytest.raises( + ValueError, match="nonexistent_mask is not part of the available masks" + ): + study.add_masks_to_time_point( + time_id=0, masks=masks, mask_mapping=mask_mapping + ) + + def test_add_masks_invalid_target_mask_raises_error(self): + """Test that invalid target mask name raises error.""" + study = TestLongitudinalStudyFixtures.create_minimal_study() + + masks = {"liver_mask": MagicMock(spec=SimpleITK.Image)} + mask_mapping = {"liver_mask": "InvalidOrgan"} + + with pytest.raises(ValueError, match="InvalidOrgan is not a valid mask name"): + study.add_masks_to_time_point( + time_id=0, masks=masks, mask_mapping=mask_mapping + ) + + +class TestLongitudinalStudyVolumeCalculations: + """Test volume and density calculations.""" + + def test_voxel_volume_calculation(self): + """Test voxel volume calculation.""" + study = TestLongitudinalStudyFixtures.create_minimal_study() + + # Mock image with known spacing + study.images[0].GetSpacing.return_value = (2.0, 3.0, 4.0) # mm + + expected_volume = ( + (2.0 / 10) * (3.0 / 10) * (4.0 / 10) + ) # Convert mm to cm then to mL + actual_volume = study.voxel_volume(time_id=0) + + assert abs(actual_volume - expected_volume) < 1e-10 + + def test_volume_of_region(self): + """Test volume calculation for a region.""" + study = TestLongitudinalStudyFixtures.create_minimal_study() + + # Create a mask with known number of voxels + mask = np.zeros((10, 10, 10), dtype=np.bool_) + mask[0:5, 0:5, 0:5] = True # 125 voxels + study.masks[0] = {"Liver": mask} + + # Mock voxel volume + study.voxel_volume = MagicMock(return_value=0.001) # 1 mm³ = 0.001 mL + + volume = study.volume_of(region="Liver", time_id=0) + + assert volume == 125 * 0.001 # 125 voxels * 0.001 mL/voxel + + +class TestLongitudinalStudyPropertyBased: + """Property-based tests for LongitudinalStudy.""" + + @pytest.mark.parametrize("num_timepoints", [1, 2, 5, 10]) + def test_consistent_timepoint_keys(self, num_timepoints): + """Property: images and meta should always have same keys.""" + study = TestLongitudinalStudyFixtures.create_minimal_study( + num_timepoints=num_timepoints + ) + + assert study.images.keys() == study.meta.keys() + assert len(study.images) == num_timepoints + assert len(study.meta) == num_timepoints + + @pytest.mark.parametrize("shape", [(5, 5, 5), (10, 20, 30)]) + def test_array_transpose_consistency(self, shape): + """Property: array_at should consistently transpose dimensions.""" + study = TestLongitudinalStudyFixtures.create_minimal_study(image_shape=shape) + + with patch("SimpleITK.GetArrayFromImage") as mock_get_array: + # SimpleITK returns arrays in (z, y, x) order + test_array = np.random.rand(*shape[::-1]) # shape[::-1] gives (z, y, x) + mock_get_array.return_value = test_array + + result = study.array_at(time_id=0) + + # Result should be transposed (1, 2, 0) from (z, y, x) to (y, x, z) + # Original shape: (shape[2], shape[1], shape[0]) = (z, y, x) + # After transpose (1, 2, 0): (shape[1], shape[0], shape[2]) = (y, x, z) + expected_shape = (shape[1], shape[0], shape[2]) + assert result.shape == expected_shape + + @pytest.mark.parametrize( + "mask_name,expected", + [ + # Valid organ names + ("Liver", True), + ("Spleen", True), + ("Kidney_Left", True), + ("Kidney_Right", True), + ("Bladder", True), + ("BoneMarrow", True), + ("WholeBody", True), + # Valid lesion formats + ("Lesion_1", True), + ("Lesion_42", True), + ("Lesion_99999", True), + # Invalid formats + ("lesion_1", False), # lowercase + ("LESION_1", False), # uppercase + ("leSIon_1", False), # mixed case + ("Lesion_0", False), # zero not allowed + ("Lesion_01", False), # leading zero + ("Lesion_", False), # missing number + ("Lesion_a", False), # non-numeric + ("Lesion_1a", False), # mixed alphanumeric + ("Lesion 1", False), # space instead of underscore + ("Lesion-1", False), # hyphen instead of underscore + # Edge cases + ("", False), # empty string + ("Kidneys", False), # not in valid list (plural vs Kidney_Left/Right) + ("Lungs", False), # not in valid list + ("Tumor", False), # not in valid list (use TotalTumorBurden or Lesion_N) + ("Background", False), # not in valid list + ("Unknown", False), # not in valid list + ("Random", False), # not in valid list + ("Lesion_-1", False), # negative number + ], + ) + def test_mask_validation(self, mask_name, expected): + """Test comprehensive mask name validation patterns.""" + from pytheranostics.imaging_ds.longitudinal_study import LongitudinalStudy + + result = LongitudinalStudy._is_valid_mask_name(mask_name) + assert ( + result == expected + ), f"Expected {mask_name} to be {expected}, got {result}" + + +class TestLongitudinalStudyIntegration: + """Integration tests that test multiple components working together.""" + + @patch("SimpleITK.GetArrayFromImage") + def test_end_to_end_activity_calculation_with_mask(self, mock_get_array): + """Integration test: Create study, add mask, calculate activity.""" + # Setup + study = TestLongitudinalStudyFixtures.create_minimal_study(modality="NM") + + # Mock array data - uniform activity of 100 Bq/ml + activity_data = np.full((10, 10, 10), 100.0) + mock_get_array.return_value = activity_data + + # Add a liver mask covering half the volume + liver_mask = np.zeros((10, 10, 10), dtype=np.bool_) + liver_mask[0:5, :, :] = True # Half the volume + study.masks[0] = {"Liver": liver_mask} + + # Mock voxel volume + study.voxel_volume = MagicMock(return_value=0.001) # 1 mm³ + + # Test + result = study.array_of_activity_at(time_id=0, region="Liver") + + # Verify + # Should have activity only in masked region + assert np.sum(result > 0) == 500 # 5*10*10 voxels + assert np.all(result[5:, :, :] == 0) # No activity outside mask + assert np.all(result[0:5, :, :] > 0) # Activity inside mask + + def test_multiple_timepoints_consistency(self): + """Integration test: Verify behavior is consistent across timepoints.""" + study = TestLongitudinalStudyFixtures.create_minimal_study(num_timepoints=3) + + # All timepoints should have same valid modality + assert all(study.modality == "NM" for _ in range(3)) + + # Should be able to access voxel volume for all timepoints + for time_id in [0, 1, 2]: + vol = study.voxel_volume(time_id) + assert isinstance(vol, float) + assert vol > 0 + + +class TestLongitudinalStudyEdgeCases: + """Test edge cases and boundary conditions.""" + + def test_empty_study_behavior(self): + """Test behavior when creating study with no timepoints.""" + # Empty study should be created successfully but have empty dictionaries + study = LongitudinalStudy(images={}, meta={}, modality="NM") + + assert len(study.images) == 0 + assert len(study.meta) == 0 + assert len(study.masks) == 0 + assert study.modality == "NM" + + @patch("SimpleITK.GetArrayFromImage") + def test_activity_calculation_with_zero_volume_mask(self, mock_get_array): + """Test behavior when mask has no True values.""" + study = TestLongitudinalStudyFixtures.create_minimal_study(modality="NM") + + mock_get_array.return_value = np.full((10, 10, 10), 100.0) + study.voxel_volume = MagicMock(return_value=0.001) + + # Create empty mask (all False) + empty_mask = np.zeros((10, 10, 10), dtype=np.bool_) + study.masks[0] = {"EmptyRegion": empty_mask} + + result = study.array_of_activity_at(time_id=0, region="EmptyRegion") + + # Should return array of all zeros + assert np.all(result == 0) + assert result.shape == (10, 10, 10) + + +class TestLongitudinalStudyPerformance: + """Test performance characteristics and scaling behavior.""" + + def test_large_timepoint_initialization(self): + """Test that initialization scales reasonably with number of timepoints.""" + import time + + start_time = time.time() + study = TestLongitudinalStudyFixtures.create_minimal_study(num_timepoints=100) + end_time = time.time() + + # Should complete in reasonable time (less than 1 second) + assert (end_time - start_time) < 1.0 + assert len(study.images) == 100 + assert len(study.meta) == 100 + + @pytest.mark.parametrize("image_size", [(5, 5, 5), (50, 50, 50)]) + def test_memory_efficiency_with_mocks(self, image_size): + """Test that our mocking doesn't consume excessive memory.""" + study = TestLongitudinalStudyFixtures.create_minimal_study( + num_timepoints=10, image_shape=image_size + ) + + # Should be able to create study regardless of declared image size + # since we're using mocks + assert len(study.images) == 10 + assert all(img.GetSize() == image_size for img in study.images.values()) + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/test_radioactive_decay.py b/tests/test_radioactive_decay.py new file mode 100644 index 0000000..45f34ad --- /dev/null +++ b/tests/test_radioactive_decay.py @@ -0,0 +1,58 @@ +"""Tests for the radioactive decay module.""" + +import numpy as np +import pytest + +from pytheranostics.shared.radioactive_decay import decay_act + + +def test_decay_act(): + """Test the decay_act function.""" + # Test with known values + initial_activity = 1000 # MBq + half_life = 6.0 # hours + time = 12.0 # hours + + expected = initial_activity * np.exp(-np.log(2) * time / half_life) + result = decay_act(initial_activity, time, half_life) + + assert np.isclose(result, expected, rtol=1e-10) + + +def test_decay_act_array(): + """Test decay_act with array inputs.""" + initial_activity = np.array([1000, 2000]) + half_life = 6.0 + time = np.array([6.0, 12.0]) + + expected = initial_activity * np.exp(-np.log(2) * time / half_life) + result = decay_act(initial_activity, time, half_life) + + assert np.allclose(result, expected, rtol=1e-10) + + +def test_invalid_inputs(): + """Test that invalid inputs raise appropriate errors.""" + with pytest.raises(ValueError): + decay_act(-1000, 6.0, 12.0) # Negative activity + + with pytest.raises(ValueError): + decay_act(1000, -6.0, 12.0) # Negative half-life + + with pytest.raises(ValueError): + decay_act(1000, 6.0, -12.0) # Negative time + + +# def test_get_activity_at_injection(): +# """Test the get_activity_at_injection function.""" +# current_activity = 500 # MBq +# half_life = 6.0 # hours +# time_since_injection = 12.0 # hours + +# expected = current_activity * np.exp(np.log(2) * time_since_injection / half_life) +# result = decay_act(current_activity, time_since_injection, half_life) +# result = get_activity_at_injection( +# current_activity, half_life, time_since_injection +# ) + +# assert np.isclose(result, expected, rtol=1e-10) diff --git a/tests/test_resource_loading.py b/tests/test_resource_loading.py new file mode 100644 index 0000000..57ec9f0 --- /dev/null +++ b/tests/test_resource_loading.py @@ -0,0 +1,19 @@ +"""Tests for data access via importlib.resources-based helpers.""" + +import numpy as np + +from pytheranostics.dosimetry.dvk import DoseVoxelKernel +from pytheranostics.dosimetry.olinda import load_phantom_mass + + +def test_load_phantom_mass_returns_expected_value(): + """The packaged phantom mass table should include standard organs.""" + liver_mass = load_phantom_mass(gender="Male", organ="Liver") + assert liver_mass == 1800 # matches bundled phantom data + + +def test_dose_voxel_kernel_falls_back_to_packaged_kernel(): + """DoseVoxelKernel should load the packaged default kernel if the exact voxel size is missing.""" + kernel = DoseVoxelKernel(isotope="Lu177", voxel_size_mm=5.5) + assert kernel.kernel.shape == (51, 51, 51) + assert kernel.kernel.dtype == np.float64 diff --git a/tests/test_smoketests.py b/tests/test_smoketests.py new file mode 100644 index 0000000..4308173 --- /dev/null +++ b/tests/test_smoketests.py @@ -0,0 +1,10 @@ +"""Smoke Tests for basic setup""" + +import pytest + + +@pytest.mark.smoke +def test_basic_import() -> None: + import pytheranostics + + assert pytheranostics is not None # Use the import