diff --git a/MANIFEST.in b/MANIFEST.in index 234110f..e6aed00 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,9 +3,9 @@ include LICENSE include requirements.txt include requirements-dev.txt recursive-include sirf_simind_connection/data *.atn -recursive-include docs *.rst *.py *.md +recursive-include docs *.rst *.py recursive-include tests *.py -recursive-include examples *.py *.md +recursive-include examples *.py global-exclude __pycache__ global-exclude *.py[co] -global-exclude .DS_Store \ No newline at end of file +global-exclude .DS_Store diff --git a/README.md b/README.md index 64cd9b8..f1527a9 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,64 @@ -# SIRF-SIMIND-Connection +# py-smc + +Python SIMIND Monte Carlo Connector. [![Tests](https://github.com/samdporter/SIRF-SIMIND-Connection/workflows/Tests/badge.svg)](https://github.com/samdporter/SIRF-SIMIND-Connection/actions) [![Documentation Status](https://readthedocs.org/projects/sirf-simind-connection/badge/?version=latest)](https://sirf-simind-connection.readthedocs.io/en/latest/?badge=latest) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Python Version](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/downloads/) -A Python wrapper for SIRF and SIMIND integration for SPECT imaging. +A Python toolkit that lets you run SIMIND from Python and use the outputs in +common reconstruction ecosystems (STIR, SIRF, PyTomography). + +## Disclaimer + +This project is an independent Python connector toolkit and is **not +affiliated with, endorsed by, or maintained by** the SIMIND project or Lund +University. + +SIMIND is **not distributed** with this package. You must separately obtain and +install a licensed SIMIND installation. ## Quick Links -- [Full Documentation](https://SIRF-SIMIND-Connection.readthedocs.io/) -- [Installation](https://SIRF-SIMIND-Connection.readthedocs.io/en/latest/installation.html) -- [Backend Support](https://sirf-simind-connection.readthedocs.io/en/latest/backends.html) - SIRF and STIR Python compatibility +- [Full Documentation](https://sirf-simind-connection.readthedocs.io/) +- [Installation](https://sirf-simind-connection.readthedocs.io/en/latest/installation.html) +- [Backend Support](https://sirf-simind-connection.readthedocs.io/en/latest/backends.html) - adaptor dependency matrix + +## What This Package Does +1. Runs SIMIND from Python with a minimal, explicit API. +2. Adapts SIMIND data for widely used Python reconstruction packages. ## Key Features -- **Dual Backend Support** - Works with both SIRF and STIR Python -- **Connector/Adaptor API** - Python Connector plus STIR/SIRF/PyTomography adaptors -- SIRF integrated Monte Carlo SPECT Simulation using SIMIND -- Dual Scoring Routines (SCATTWIN/PENETRATE) -- DICOM-driven adaptor examples (STIR/SIRF/PyTomography) -- **Advanced Schneider2000 Density Conversion** - Clinically validated HU-to-density mapping with 44 tissue segments +- **Connector-first API** - `SimindPythonConnector` for direct SIMIND execution from Python +- **Package Adaptors** - STIR/SIRF/PyTomography adaptors for reconstruction workflows +- **No reconstruction reimplementation** - Reconstruction stays inside target packages +- **Dual scoring support** - SCATTWIN and PENETRATE +- **DICOM builders** - DICOM-driven setup utilities for scanner/input preparation +- **Advanced Schneider2000 density conversion** - 44-segment HU-to-density mapping ## Installation ### Basic Installation ```bash -pip install sirf-simind-connection +pip install py-smc +``` + +Import path remains: + +```python +import sirf_simind_connection ``` -### Backend Requirements +### Adaptor Dependencies -SIRF-SIMIND-Connection requires either **SIRF** or **STIR Python** as a backend. The backend is auto-detected at runtime, with SIRF preferred if both are available. See the [backend guide](https://sirf-simind-connection.readthedocs.io/en/latest/backends.html) for details. +`SimindPythonConnector` works without SIRF/STIR/PyTomography. + +Install optional packages only for the adaptor paths you need: + +- **STIR Python** for `StirSimindAdaptor` workflows (example 07A) +- **SIRF** for `SirfSimindAdaptor` workflows (example 07B) +- **PyTomography** for `PyTomographySimindAdaptor` workflows (example 07C) #### Option 1: STIR Python (Recommended for basic usage) @@ -48,12 +76,7 @@ cd STIR # Follow build instructions in the repository ``` -#### Option 2: SIRF (Required for advanced features) - -SIRF is required for: -- Coordinator/Projector functionality -- CIL integration -- SIRF-native OSEM reconstruction (example 07B) +#### Option 2: SIRF Install from source: ```bash @@ -62,18 +85,26 @@ cd SIRF # Follow build instructions in the repository ``` -**Note**: SIRF includes STIR, so you don't need to install STIR separately if using SIRF. +**Note**: SIRF includes STIR, so a separate STIR install is usually unnecessary. + +#### Option 3: PyTomography + +Install PyTomography for the PyTomography adaptor workflow: + +```bash +pip install pytomography +``` ### SIMIND Requirement -SIMIND is **not included** in this repository and must be installed separately. +SIMIND is **not included** with this package and must be installed separately. Use the official SIMIND resources: - SIMIND site (Medical Radiation Physics, Lund University): https://www.msf.lu.se/en/research/simind-monte-carlo-program - SIMIND manual/docs: https://www.msf.lu.se/en/research/simind-monte-carlo-program/manual -For quick local use in this repository, place your local SIMIND installation under: +For local use with this package's scripts, place your SIMIND installation under: ```text ./simind @@ -91,32 +122,41 @@ and ensure SIMIND data files are available under: ./simind/smc_dir/ ``` -Docker scripts are configured to use this repo-local layout and automatically -wire SIMIND paths when the binary exists at `./simind/simind`. +The Docker helper scripts use this layout and automatically wire SIMIND paths +when the binary exists at `./simind/simind`. ## Quick Start ```python -from sirf_simind_connection import SimindSimulator, SimulationConfig +import numpy as np +from sirf_simind_connection import SimindPythonConnector from sirf_simind_connection.configs import get -from sirf_simind_connection.utils.stir_utils import create_simple_phantom, create_attenuation_map -# Create phantom and attenuation map -phantom = create_simple_phantom() -mu_map = create_attenuation_map(phantom) +source = np.zeros((32, 32, 32), dtype=np.float32) # z, y, x +source[12:20, 12:20, 12:20] = 1.0 +mu_map = np.zeros_like(source) +mu_map[source > 0] = 0.15 -# Load pre-configured scanner settings -config = SimulationConfig(get("AnyScan.yaml")) -simulator = SimindSimulator(config, output_dir='output') - -# Set inputs and run -simulator.set_source(phantom) -simulator.set_mu_map(mu_map) -simulator.set_energy_windows([126], [154], [0]) # Tc-99m ± 10% -simulator.run_simulation() +connector = SimindPythonConnector( + config_source=get("Example.yaml"), + output_dir="output/basic", + output_prefix="case01", + quantization_scale=0.05, +) -# Access results as native SIRF/STIR objects when needed -native_outputs = simulator.get_outputs(native=True) -sirf_tot = simulator.get_total_output(native=True) +connector.configure_voxel_phantom( + source=source, + mu_map=mu_map, + voxel_size_mm=4.0, +) +connector.set_energy_windows([126], [154], [0]) # Tc-99m ± 10% +connector.add_runtime_switch("FI", "tc99m") +connector.add_runtime_switch("CC", "ma-lehr") +connector.add_runtime_switch("NN", 1) +connector.add_runtime_switch("RR", 12345) + +outputs = connector.run() +total = outputs["tot_w1"].projection +print(total.shape) ``` ### Advanced Density Conversion diff --git a/docker/pytomography/Dockerfile b/docker/pytomography/Dockerfile index 484e2bb..b77155f 100644 --- a/docker/pytomography/Dockerfile +++ b/docker/pytomography/Dockerfile @@ -17,7 +17,7 @@ ENV PATH=${SIMIND_ROOT}/bin:${SIMIND_ROOT}:${SIRF_INSTALL_PATH}/bin:${PATH} WORKDIR /workspace -COPY . /workspace +COPY --chown=1000:100 . /workspace RUN python -m pip install --upgrade pip \ && pip install --index-url https://download.pytorch.org/whl/cpu torch \ diff --git a/docker/sirf/Dockerfile b/docker/sirf/Dockerfile index 2987008..b83107b 100644 --- a/docker/sirf/Dockerfile +++ b/docker/sirf/Dockerfile @@ -17,7 +17,7 @@ ENV PATH=${SIMIND_ROOT}/bin:${SIMIND_ROOT}:${SIRF_INSTALL_PATH}/bin:${PATH} WORKDIR /workspace -COPY . /workspace +COPY --chown=1000:100 . /workspace RUN python -m pip install --upgrade pip \ && pip install -e ".[dev,examples]" \ diff --git a/docker/stir/Dockerfile b/docker/stir/Dockerfile index b73fab4..01513e8 100644 --- a/docker/stir/Dockerfile +++ b/docker/stir/Dockerfile @@ -17,7 +17,7 @@ ENV PATH=${SIMIND_ROOT}/bin:${SIMIND_ROOT}:${SIRF_INSTALL_PATH}/bin:${PATH} WORKDIR /workspace -COPY . /workspace +COPY --chown=1000:100 . /workspace RUN python -m pip install --upgrade pip \ && pip install -e ".[dev,examples]" \ diff --git a/docs/api.rst b/docs/api.rst index 93d7fb9..a920b43 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,49 +3,51 @@ API Documentation ================= -Core Modules ------------- +Top-Level Package +----------------- .. automodule:: sirf_simind_connection :members: :undoc-members: :show-inheritance: -Simulator and Configuration -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Core +---- -.. automodule:: sirf_simind_connection.core.simulator +.. automodule:: sirf_simind_connection.core.config :members: :undoc-members: :show-inheritance: -.. automodule:: sirf_simind_connection.core.config +.. automodule:: sirf_simind_connection.core.executor :members: :undoc-members: :show-inheritance: Connectors and Adaptors -~~~~~~~~~~~~~~~~~~~~~~~ +----------------------- .. automodule:: sirf_simind_connection.connectors :members: :undoc-members: :show-inheritance: -Core Runtime Modules -~~~~~~~~~~~~~~~~~~~~ +.. automodule:: sirf_simind_connection.connectors.python_connector + :members: + :undoc-members: + :show-inheritance: -.. automodule:: sirf_simind_connection.core.backend_adapter +.. automodule:: sirf_simind_connection.connectors.stir_adaptor :members: :undoc-members: :show-inheritance: -.. automodule:: sirf_simind_connection.core.file_managers +.. automodule:: sirf_simind_connection.connectors.sirf_adaptor :members: :undoc-members: :show-inheritance: -.. automodule:: sirf_simind_connection.core.output_processor +.. automodule:: sirf_simind_connection.connectors.pytomography_adaptor :members: :undoc-members: :show-inheritance: @@ -53,25 +55,11 @@ Core Runtime Modules Converters ---------- -Attenuation and Density Conversion -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - .. automodule:: sirf_simind_connection.converters.attenuation :members: :undoc-members: :show-inheritance: -Key Functions: - -* :func:`~sirf_simind_connection.converters.attenuation.hu_to_density` - Traditional bilinear HU-to-density conversion -* :func:`~sirf_simind_connection.converters.attenuation.hu_to_density_schneider` - Advanced Schneider2000 interpolated conversion -* :func:`~sirf_simind_connection.converters.attenuation.hu_to_density_schneider_piecewise` - Exact Schneider2000 piecewise conversion -* :func:`~sirf_simind_connection.converters.attenuation.get_schneider_tissue_info` - Tissue information lookup -* :func:`~sirf_simind_connection.converters.attenuation.compare_density_methods` - Method comparison utility - -DICOM and SIMIND Converters -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - .. automodule:: sirf_simind_connection.converters.dicom_to_stir :members: :undoc-members: @@ -85,9 +73,6 @@ DICOM and SIMIND Converters Utilities --------- -STIR and SIMIND Utilities -~~~~~~~~~~~~~~~~~~~~~~~~~~ - .. automodule:: sirf_simind_connection.utils.stir_utils :members: :undoc-members: @@ -107,5 +92,3 @@ STIR and SIMIND Utilities :members: :undoc-members: :show-inheritance: - -Explore the different modules and their functionalities. This section is auto-generated using Sphinx autodoc. diff --git a/docs/backends.rst b/docs/backends.rst index b84380b..db15a0a 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -1,356 +1,57 @@ -Backend Abstraction Layer -========================= +Backend and Adaptor Dependencies +================================ -For geometry/axis conventions across SIMIND, STIR/SIRF, and PyTomography, -see :doc:`geometry`. +The package uses connector-first execution: SIMIND execution is handled by +``SimindPythonConnector`` and adaptor classes layer package-specific object +conversion on top. -This module provides a unified interface for working with both **SIRF** and **STIR Python** libraries, allowing users to choose their preferred backend for image reconstruction and simulation tasks. - -Overview --------- - -The backend system automatically detects which library is available and uses it transparently. Users can also manually select a backend if both libraries are installed. - -Supported Backends -~~~~~~~~~~~~~~~~~~ - -- **SIRF** (``sirf.STIR``): Full-featured SIRF interface with all reconstruction capabilities -- **STIR Python** (``stir`` + ``stirextra``): Direct STIR Python bindings - -Quick Start ------------ - -Automatic Backend Detection -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - from sirf_simind_connection import SimindSimulator - from sirf_simind_connection.backends import get_backend - - # Automatically uses SIRF if available, otherwise STIR Python - print(f"Using backend: {get_backend()}") - - # Use the simulator normally - backend is handled internally - simulator = SimindSimulator(config, output_dir="output") - -Manual Backend Selection -~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - from sirf_simind_connection.backends import set_backend - - # Force STIR Python backend - set_backend("stir") - - # Now all operations will use STIR Python - from sirf_simind_connection import SimindSimulator - simulator = SimindSimulator(config, output_dir="output") - -API Reference -------------- - -Factory Functions -~~~~~~~~~~~~~~~~~ - -``get_backend() -> str`` -^^^^^^^^^^^^^^^^^^^^^^^^ - -Returns the current backend ("sirf" or "stir"). Auto-detects if not set. - -``set_backend(backend: str) -> None`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Manually set the backend. Options: "sirf" or "stir". - -``create_image_data(filepath: str = None) -> ImageDataInterface`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Create an image data object using the current backend. - -.. code-block:: python - - from sirf_simind_connection.backends import create_image_data - - # Load from file - img = create_image_data("phantom.hv") - - # Access data - arr = img.as_array() # Works with both backends - img.write("output.hv") - -``create_acquisition_data(filepath: str = None) -> AcquisitionDataInterface`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Create an acquisition data object using the current backend. - -.. code-block:: python - - from sirf_simind_connection.backends import create_acquisition_data - - # Load from file - acq = create_acquisition_data("projections.hs") - - # Access data - arr = acq.as_array() # Works with both backends - acq.write("output.hs") - -``unwrap(obj) -> native_object`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Get the underlying native SIRF or STIR object from a wrapped object. - -.. code-block:: python - - from sirf_simind_connection.backends import unwrap - - wrapped_img = create_image_data("phantom.hv") - native_img = unwrap(wrapped_img) # Returns ImageData or FloatVoxelsOnCartesianGrid - -Utility Functions -~~~~~~~~~~~~~~~~~ - -``is_sirf_backend() -> bool`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Check if SIRF backend is active. - -``is_stir_backend() -> bool`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Check if STIR Python backend is active. - -``reset_backend() -> None`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Reset backend selection to allow re-detection. - -Feature Compatibility ---------------------- +Dependency Matrix +----------------- .. list-table:: :header-rows: 1 - :widths: 40 20 30 - - * - Feature - - SIRF Required - - STIR Python Supported - * - Basic simulation (examples 01-06) - - No - - ✅ Yes - * - SCATTWIN scoring - - No - - ✅ Yes - * - PENETRATE scoring - - No - - ✅ Yes - * - File I/O (Interfile format) - - No - - ✅ Yes - * - Array conversion - - No - - ✅ Yes - * - **CIL integration** - - ✅ Yes - - ❌ No - * - **Coordinator architecture** - - ✅ Yes - - ❌ No - * - **SimindProjector** - - ✅ Yes - - ❌ No - * - **OSEM via STIR adaptor (example 07A)** - - ❌ No - - ✅ Yes - * - **OSEM via SIRF adaptor (example 07B)** - - ✅ Yes - - ❌ No - * - **OSEM via PyTomography adaptor (example 07C)** - - ❌ No - - ❌ No (requires PyTomography) - -Key Differences Between Backends ---------------------------------- + :widths: 45 55 -Array Conversion -~~~~~~~~~~~~~~~~ + * - Component + - Required dependency + * - ``SimindPythonConnector`` + - SIMIND only (no SIRF/STIR/PyTomography requirement) + * - ``StirSimindAdaptor`` + - STIR Python (``stir``) + * - ``SirfSimindAdaptor`` + - SIRF (``sirf.STIR``) + * - ``PyTomographySimindAdaptor`` + - PyTomography + torch -.. code-block:: python +The adaptors are responsible for converting input/output object types across +package boundaries. Reconstruction-system objects (for example, a PyTomography +system matrix) should be created directly in the target package. - # Both backends support: - from sirf_simind_connection.utils import get_array - arr = get_array(image_obj) # Works with both! +Backend Abstraction Module +-------------------------- - # Internally: - # - SIRF uses: obj.asarray() or obj.as_array() - # - STIR uses: stirextra.to_numpy(obj) +The ``sirf_simind_connection.backends`` module provides optional helper APIs for +working with SIRF/STIR image and acquisition wrapper objects. -File Writing -~~~~~~~~~~~~ +Quick example: .. code-block:: python - # Unified interface: - img.write("output.hv") - - # Internally: - # - SIRF uses: obj.write(filepath) - # - STIR uses: obj.write_to_file(filepath) - -Object Construction -~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - # Unified interface: - img = create_image_data("input.hv") - acq = create_acquisition_data("input.hs") - - # Internally: - # - SIRF: ImageData(filepath), AcquisitionData(filepath) - # - STIR: FloatVoxelsOnCartesianGrid.read_from_file(filepath), - # ProjData.read_from_file(filepath) - -Implementation Notes --------------------- - -STIR Python Limitations -~~~~~~~~~~~~~~~~~~~~~~~~ - -Some operations are not directly supported with STIR Python backend: - -1. **Creating empty objects**: STIR requires geometry information - - .. code-block:: python - - # This works with SIRF but raises NotImplementedError with STIR: - img = create_image_data() # No filepath - -2. **Filling with arrays**: STIR's ProjData requires segment-by-segment operations + from sirf_simind_connection.backends import get_backend, set_backend - .. code-block:: python + backend = get_backend() # auto-detect: "sirf" or "stir" + print(f"Using backend: {backend}") - # This works with SIRF but raises NotImplementedError with STIR: - acq.fill(numpy_array) # Works with scalars only for STIR - -3. **Element-wise operations**: Not all SIRF methods are available in STIR - - .. code-block:: python - - # SIRF only: - img.maximum(0) # Clip negative values - -Interfile Format Compatibility -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Both backends read and write the same Interfile format, so files are **fully interchangeable**: - -.. code-block:: python - - # Create with SIRF - set_backend("sirf") - img = create_image_data() - img.fill(1.0) - img.write("test.hv") - - # Read with STIR + # Optional explicit override set_backend("stir") - img2 = create_image_data("test.hv") - arr = img2.as_array() # Works! - -Examples --------- - -Running Examples with Different Backends -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: bash - - # Core Python-connector examples (backend-agnostic NumPy path) - python examples/01_basic_simulation.py - - # STIR-native adaptor + STIR OSEM - python examples/07A_stir_adaptor_osem.py - - # SIRF-native adaptor + SIRF OSEM - python examples/07B_sirf_adaptor_osem.py - - # PyTomography-native adaptor + PyTomography OSEM - python examples/07C_pytomography_adaptor_osem.py - -Testing -------- - -The backend system includes comprehensive tests: - -.. code-block:: bash - - # Test backend auto-detection - pytest tests/test_backends.py::test_auto_detection - - # Test array conversion with both backends - pytest tests/test_backends.py::test_array_conversion - - # Test file I/O compatibility - pytest tests/test_backends.py::test_file_io_compatibility - -Troubleshooting ---------------- - -ImportError: Neither SIRF nor STIR Python found -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Install one of the supported backends: - -.. code-block:: bash - - # Option 1: Install SIRF (recommended for full features) - # Follow instructions at: https://github.com/SyneRBI/SIRF - - # Option 2: Install STIR Python - # Follow instructions at: https://github.com/UCL/STIR - -Backend not switching -~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - # Force reset if backend seems stuck - from sirf_simind_connection.backends import reset_backend - reset_backend() - set_backend("stir") # Now it will switch - -Feature not available with STIR -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Check the compatibility table above. Some features require SIRF: - -- CIL integration -- Coordinator architecture -- SimindProjector/coordinator workflows - -STIR-native OSEM is supported via ``examples/07A_stir_adaptor_osem.py``. -For SIRF-only features, use SIRF backend: - -.. code-block:: python - - set_backend("sirf") - -Architecture ------------- -The backend system uses an adapter pattern:: +Main helper functions include: - User Code - ↓ - Backend Factory (auto-detect or manual) - ↓ - ┌─────────────────┬─────────────────┐ - │ SIRF Backend │ STIR Backend │ - │ (wrappers) │ (wrappers) │ - └─────────────────┴─────────────────┘ - ↓ ↓ - sirf.STIR stir + stirextra +- ``get_backend()`` +- ``set_backend(backend)`` +- ``reset_backend()`` +- ``create_image_data(...)`` +- ``create_acquisition_data(...)`` +- ``unwrap(...)`` -All wrappers implement ``ImageDataInterface`` and ``AcquisitionDataInterface`` for consistent API. +These helpers are independent from the connector/adaptor execution API. diff --git a/docs/changelog.rst b/docs/changelog.rst index 33845d6..fc92c16 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,71 @@ The format is based on `Keep a Changelog ` Unreleased ---------- +Changed +~~~~~~~ + +- Updated user-facing branding to ``py-smc`` and added explicit non-affiliation + and external-SIMIND-install disclaimers in README/docs. + +[0.5.0] - 2026-03-05 +-------------------- + +**Breaking Changes** +~~~~~~~~~~~~~~~~~~~~ + +**Connector-first public API** + The legacy simulator-oriented surface has been removed in favor of + connector/adaptor entry points: + + - Removed ``SimindSimulator`` from top-level exports. + - Removed ``NativeBackendConnector`` from connector exports. + - Removed legacy core runtime modules + (``core.simulator``, ``core.backend_adapter``, ``core.components``, + ``core.file_managers``, ``core.output_processor``). + +**PyTomography adaptor boundary tightening** + ``PyTomographySimindAdaptor`` no longer wraps reconstruction-package helper + methods: + + - Removed ``build_system_matrix()`` + - Removed ``get_pytomography_metadata()`` + + System-matrix creation is now performed directly with PyTomography APIs in + user code/examples. + +Added +~~~~~ + +- ``core.executor.SimindExecutor`` as a minimal subprocess runner for SIMIND. +- Expanded unit coverage for connector/adaptor behavior: + - native STIR/SIRF adaptor validation and forwarding behavior + - PyTomography adaptor connector wiring and axis-order forwarding + - additional ``configure_voxel_phantom`` edge cases in Python connector tests +- Geometry documentation note for STIR/SIRF voxel-size extraction fallback + behavior. + +Changed +~~~~~~~ + +- ``SimindPythonConnector`` is now the central execution path for adaptor + workflows. +- ``StirSimindAdaptor`` and ``SirfSimindAdaptor`` now delegate SIMIND setup/run + through ``SimindPythonConnector`` and return native backend projection types. +- ``PyTomographySimindAdaptor`` remains focused on SIMIND I/O adaptation and + tensor conversion only. +- README and docs pages were rewritten to user-facing, connector-first + guidance. + +Fixed +~~~~~ + +- STIR/SIRF voxel-size extraction fallback now supports both 4-element and + 3-element ``get_grid_spacing()`` outputs, plus non-iterable coordinate + objects (for example, STIR ``Float3BasicCoordinate``) for z-spacing + extraction. +- ``SimindExecutor`` command invocation hardened with explicit token validation + and ``shell=False`` execution. + [0.4.0] - 2026-02-26 -------------------- diff --git a/docs/conf.py b/docs/conf.py index 18bd58c..76626b0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,4 @@ -"""Sphinx configuration file for SIRF-SIMIND-Connection documentation.""" +"""Sphinx configuration file for py-smc documentation.""" import os import sys @@ -10,13 +10,16 @@ sys.path.insert(0, os.path.abspath("..")) # Project information -project = "SIRF-SIMIND-Connection" +project = "py-smc" copyright = f"{datetime.now().year}, Sam Porter, Efstathios Varzakis" author = "Sam Porter, Efstathios Varzakis" -try: - release = importlib_metadata.version("sirf-simind-connection") -except importlib_metadata.PackageNotFoundError: - release = "0.4.0" +release = "0.5.0" +for dist_name in ("py-smc", "sirf-simind-connection"): + try: + release = importlib_metadata.version(dist_name) + break + except importlib_metadata.PackageNotFoundError: + continue version = ".".join(release.split(".")[:2]) # General configuration diff --git a/docs/geometry.rst b/docs/geometry.rst index ebbfc58..71cea8e 100644 --- a/docs/geometry.rst +++ b/docs/geometry.rst @@ -2,7 +2,7 @@ Geometry Considerations ======================= This page summarizes the geometry conventions used across SIMIND, STIR/SIRF, -and PyTomography in this repository. +and PyTomography in this package. At-a-Glance Axis Conventions ---------------------------- @@ -35,6 +35,10 @@ Units - STIR/SIRF image geometry is typically expressed in **mm**. - Connectors handle conversion internally via voxel-size settings (for example, runtime switch ``PX`` is set in cm for SIMIND). +- STIR/SIRF adaptor voxel size extraction expects z-spacing in mm. It uses + ``voxel_sizes()[2]`` when available, and falls back to ``get_grid_spacing()`` + using the last spatial element (index 3 for 4-element spacing, index 2 for + 3-element spacing). Example Configuration Guardrails -------------------------------- @@ -43,7 +47,7 @@ The OSEM examples intentionally pin key simulation parameters so geometry checks are reproducible: - ``NN=1`` (runtime switch) for faster, deterministic iteration. -- ``config[29]=30`` for projection count. +- ``config[29]=24`` for projection count. - ``config[53]=0`` to keep collimator modeling geometric-only in these tests. - ``config[19]=2`` to keep a consistent mapping used by current examples. diff --git a/docs/index.rst b/docs/index.rst index ca0857e..ef2dd49 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,5 +1,5 @@ -SIRF-SIMIND-Connection Documentation -==================================== +py-smc Documentation +==================== .. toctree:: :maxdepth: 2 diff --git a/docs/installation.rst b/docs/installation.rst index 750a2dc..799d71c 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -31,8 +31,14 @@ Install the Python Package SIMIND Requirement (External Dependency) ---------------------------------------- -SIMIND is **not** distributed in this repository (or on PyPI) and must be -installed separately by the user. +Disclaimer +~~~~~~~~~~ + +This project is independent and is **not affiliated with, endorsed by, or +maintained by** the SIMIND project or Lund University. + +SIMIND is **not** distributed with this package (or on PyPI) and must be +installed separately. Use the official SIMIND resources for installation and manual/reference documentation: @@ -45,7 +51,7 @@ documentation: Recommended Local Layout ~~~~~~~~~~~~~~~~~~~~~~~~ -For this repository's scripts and Docker setup, place SIMIND under: +For the package scripts and Docker setup, place SIMIND under: .. code-block:: text @@ -78,9 +84,9 @@ the SIMIND data directory: Docker Behavior ~~~~~~~~~~~~~~~ -The Docker Compose services and container helper scripts are configured for the -repo-local SIMIND layout above. They automatically wire SIMIND paths inside the -containers when ``./simind/simind`` is present. +The Docker Compose services and container helper scripts use the local SIMIND +layout above. They automatically wire SIMIND paths inside the containers when +``./simind/simind`` is present. Quick Verification ------------------ diff --git a/docs/intro.rst b/docs/intro.rst index adea8a9..74a6e55 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -3,12 +3,28 @@ Introduction ============ -Welcome to the SIRF-SIMIND-Connection documentation! +py-smc is a Python toolkit for SIMIND SPECT workflows. -This package provides a seamless integration between the SIRF (Synergistic Image Reconstruction Framework) and the SIMIND Monte Carlo simulator for advanced SPECT imaging applications. +Disclaimer +---------- -Key Features: -- **Monte Carlo Simulations**: Allows detailed SPECT analysis. -- **Flexible Configuration**: Use YAML to easily configure simulations. -- **Extensive Use Cases**: Suitable for both clinical and research purposes. +This project is independent and is **not affiliated with, endorsed by, or +maintained by** the SIMIND project or Lund University. +For users, the package provides two core capabilities: + +1. Run SIMIND from Python through a small, direct API. +2. Adapt SIMIND outputs to well-used Python reconstruction packages. + +In practice this means: + +- ``SimindPythonConnector`` runs SIMIND and returns NumPy-first outputs. +- ``StirSimindAdaptor`` bridges SIMIND outputs into STIR-native objects. +- ``SirfSimindAdaptor`` bridges SIMIND outputs into SIRF-native objects. +- ``PyTomographySimindAdaptor`` bridges SIMIND outputs into torch/PyTomography workflows. + +Reconstruction algorithms are intentionally left to the target packages +(STIR, SIRF, PyTomography). The adaptor layer handles data conversion and +I/O boundaries, not reconstruction-method wrappers. + +For axis and geometry conventions across these ecosystems, see :doc:`geometry`. diff --git a/docs/testing.rst b/docs/testing.rst index 67d3015..b953cdb 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -3,7 +3,10 @@ Testing ======= -This document explains the testing strategy for SIRF-SIMIND-Connection, which handles the challenge of testing code that depends on optional external dependencies (SIRF, STIR, SIMIND, and PyTomography) that may not be available in every environment. +This document explains the testing strategy for py-smc, which handles the +challenge of testing code that depends on optional external dependencies (SIRF, +STIR, SIMIND, and PyTomography) that may not be available in every +environment. Test Organization ----------------- @@ -102,7 +105,7 @@ skip those checks by default. Use ``--require-simind`` to fail fast instead. ``input.smc`` remains packaged in ``sirf_simind_connection/configs`` and is not part of the SIMIND runtime availability check. -SIMIND itself is not bundled with this repository; install it separately from +SIMIND itself is not bundled with this package; install it separately from the official SIMIND site and manual: * https://www.msf.lu.se/en/research/simind-monte-carlo-program diff --git a/docs/usage.rst b/docs/usage.rst index 8cf9cd6..4b50bb6 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -1,126 +1,148 @@ .. _usage: Usage Guide -============ +=========== -Getting Started ----------------- +Connector-First Quick Start +--------------------------- -A quick example to get started with SIRF-SIMIND-Connection: +Use ``SimindPythonConnector`` when you want direct Python control of SIMIND +inputs/outputs without any reconstruction-package dependency. .. code-block:: python - from sirf_simind_connection import SimindSimulator, SimulationConfig + import numpy as np + from sirf_simind_connection import SimindPythonConnector from sirf_simind_connection.configs import get - from sirf_simind_connection.utils.stir_utils import create_simple_phantom, create_attenuation_map - # Create phantom and attenuation map - phantom = create_simple_phantom() - mu_map = create_attenuation_map(phantom) + source = np.zeros((32, 32, 32), dtype=np.float32) # z, y, x + source[12:20, 12:20, 12:20] = 1.0 + mu_map = np.zeros_like(source) + mu_map[source > 0] = 0.15 - # Load pre-configured scanner settings - config = SimulationConfig(get("AnyScan.yaml")) - simulator = SimindSimulator(config, output_dir='output') + connector = SimindPythonConnector( + config_source=get("Example.yaml"), + output_dir="output/basic", + output_prefix="case01", + quantization_scale=0.05, + ) - # Set inputs and run - simulator.set_source(phantom) - simulator.set_mu_map(mu_map) - simulator.set_energy_windows([126], [154], [0]) # Tc-99m ± 10% - simulator.run_simulation() + connector.configure_voxel_phantom( + source=source, + mu_map=mu_map, + voxel_size_mm=4.0, + ) + connector.set_energy_windows([126], [154], [0]) + connector.add_runtime_switch("FI", "tc99m") + connector.add_runtime_switch("CC", "ma-lehr") + connector.add_runtime_switch("NN", 1) + connector.add_runtime_switch("RR", 12345) - result = simulator.get_total_output(window=1) - print("Simulation completed successfully.") + outputs = connector.run() + total = outputs["tot_w1"].projection + print(total.shape) -Density Conversion ------------------- +Adaptor Workflows +----------------- -The package provides advanced Hounsfield Unit (HU) to density conversion methods: +Use adaptors when you want connector-managed SIMIND execution plus native +objects for a target reconstruction package. -Traditional Bilinear Model -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +STIR adaptor +~~~~~~~~~~~~ .. code-block:: python - from sirf_simind_connection.converters.attenuation import hu_to_density - import numpy as np - - # Simple 3-point model (air, water, bone) - hu_image = np.array([[-1000, 0, 500], [800, 1200, 2000]]) - density_bilinear = hu_to_density(hu_image) - -Advanced Schneider2000 Model -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python + from sirf_simind_connection import StirSimindAdaptor + from sirf_simind_connection.configs import get - from sirf_simind_connection.converters.attenuation import ( - hu_to_density_schneider, - hu_to_density_schneider_piecewise, - get_schneider_tissue_info, - compare_density_methods + adaptor = StirSimindAdaptor( + config_source=get("Example.yaml"), + output_dir="output/stir_adaptor", + output_prefix="stir_case01", ) + adaptor.set_source(stir_source) + adaptor.set_mu_map(stir_mu_map) + adaptor.set_energy_windows([75], [225], [0]) + adaptor.add_runtime_switch("FI", "y90_tissue") + adaptor.add_runtime_switch("CC", "ma-megp") + adaptor.add_runtime_switch("RR", 12345) - # Advanced 44-segment piecewise model - density_schneider = hu_to_density_schneider(hu_image) - density_piecewise = hu_to_density_schneider_piecewise(hu_image) + outputs = adaptor.run() + stir_total = outputs["tot_w1"] - # Lookup tissue information - tissue_info = get_schneider_tissue_info(50) # HU = 50 - print(f"Tissue: {tissue_info['name']}") - print(f"Density: {tissue_info['density_g_cm3']:.3f} g/cm³") +SIRF adaptor +~~~~~~~~~~~~ - # Compare methods - comparison = compare_density_methods(hu_image) - print(f"Mean difference: {comparison['mean_diff_interp']:.3f} g/cm³") - -**Key Advantages of Schneider Model:** +.. code-block:: python -- **44 tissue segments** vs 3 points (bilinear) -- **Clinically validated densities** from Schneider et al. 2000 -- **Better accuracy** especially for lung and bone regions -- **Metal implant support** - handles dental materials and implants -- **~0.17-0.19 g/cm³ improved accuracy** over bilinear model + from sirf_simind_connection import SirfSimindAdaptor + from sirf_simind_connection.configs import get -Detailed Use Cases --------------------- + adaptor = SirfSimindAdaptor( + config_source=get("Example.yaml"), + output_dir="output/sirf_adaptor", + output_prefix="sirf_case01", + ) + adaptor.set_source(sirf_source) + adaptor.set_mu_map(sirf_mu_map) + adaptor.set_energy_windows([75], [225], [0]) + adaptor.add_runtime_switch("FI", "y90_tissue") + adaptor.add_runtime_switch("CC", "ma-megp") + adaptor.add_runtime_switch("RR", 12345) -1. **Basic Simulation** - Learn how to set up and run simple simulations. -2. **Advanced Configuration** - Using custom YAML configurations. -3. **Density Conversion** - Choose between bilinear and Schneider models for HU-to-density conversion. -4. **Extensive Output Analysis** - Understand the output from SCATTWIN vs PENETRATE routines. + outputs = adaptor.run() + sirf_total = outputs["tot_w1"] -Pure Python Connector ---------------------- +PyTomography adaptor +~~~~~~~~~~~~~~~~~~~~ -Use the new NumPy-first connector when you want a backend-agnostic SIMIND run: +The adaptor returns PyTomography-compatible tensors and output headers. +Build the system matrix directly with PyTomography APIs. .. code-block:: python - from sirf_simind_connection import RuntimeOperator, SimindPythonConnector + import torch + from pytomography.io.SPECT import simind as pytomo_simind + from pytomography.projectors.SPECT import SPECTSystemMatrix + + from sirf_simind_connection import PyTomographySimindAdaptor from sirf_simind_connection.configs import get - connector = SimindPythonConnector( - config_source=get("AnyScan.yaml"), - output_dir="output/python_connector", - output_prefix="case01", - quantization_scale=1.0, + adaptor = PyTomographySimindAdaptor( + config_source=get("Example.yaml"), + output_dir="output/pytomo_adaptor", + output_prefix="pytomo_case01", ) - - outputs = connector.run( - RuntimeOperator( - switches={"NN": 1, "RR": 12345}, - ) + adaptor.set_source(source_tensor_xyz) + adaptor.set_mu_map(mu_tensor_xyz) + adaptor.set_energy_windows([75], [225], [0]) + adaptor.add_runtime_switch("FI", "y90_tissue") + adaptor.add_runtime_switch("CC", "ma-megp") + adaptor.add_runtime_switch("RR", 12345) + adaptor.run() + + projections = adaptor.get_total_output(window=1).to(dtype=torch.float32) + header = adaptor.get_output_header_path("tot_w1") + object_meta, proj_meta = pytomo_simind.get_metadata(str(header)) + + system_matrix = SPECTSystemMatrix( + obj2obj_transforms=[], + proj2proj_transforms=[], + object_meta=object_meta, + proj_meta=proj_meta, ) - total = outputs["tot_w1"] - print(total.projection.shape) - print(total.header_path) +Density Conversion +------------------ -``quantization_scale`` controls source integer quantization before writing SIMIND -``.smi`` files: +The package includes HU-to-density conversion utilities, including the +Schneider2000 model. -- ``1.0`` uses the full internal source integer range (best numeric precision) -- values below ``1.0`` run faster for toy examples but increase rounding error +.. code-block:: python -SIMIND treats source maps as integer weights; absolute scaling is controlled by -activity/time simulation settings. + import numpy as np + from sirf_simind_connection.converters.attenuation import hu_to_density_schneider + + hu_image = np.array([[-1000, 0, 500], [800, 1200, 2000]]) + density_map = hu_to_density_schneider(hu_image) diff --git a/examples/01_basic_simulation.py b/examples/01_basic_simulation.py index f3f1ab8..110730e 100644 --- a/examples/01_basic_simulation.py +++ b/examples/01_basic_simulation.py @@ -15,10 +15,8 @@ from _python_connector_helpers import ( add_standard_runtime, build_small_phantom_zyx, - configure_voxel_input, projection_view0, require_simind, - write_windows, ) from sirf_simind_connection import SimindPythonConnector, configs @@ -38,14 +36,16 @@ def main() -> None: ) source, mu_map = build_small_phantom_zyx() - source_path, density_path = configure_voxel_input( - connector, - source, - mu_map, + source_path, density_path = connector.configure_voxel_phantom( + source=source, + mu_map=mu_map, voxel_size_mm=4.0, scoring_routine=1, ) - write_windows(connector, [126.0], [154.0], [0]) + connector.add_config_value(1, 140.0) + connector.add_config_value(19, 2) + connector.add_config_value(53, 0) + connector.set_energy_windows([126.0], [154.0], [0]) add_standard_runtime(connector, photon_multiplier=1, seed=12345, nuclide="tc99m") outputs = connector.run() diff --git a/examples/02_runtime_switch_comparison.py b/examples/02_runtime_switch_comparison.py index d97b8e7..f33dd2e 100644 --- a/examples/02_runtime_switch_comparison.py +++ b/examples/02_runtime_switch_comparison.py @@ -15,10 +15,8 @@ from _python_connector_helpers import ( add_standard_runtime, build_small_phantom_zyx, - configure_voxel_input, projection_view0, require_simind, - write_windows, ) from sirf_simind_connection import SimindPythonConnector, configs @@ -37,14 +35,16 @@ def _run_case( output_prefix=prefix, quantization_scale=0.05, ) - configure_voxel_input( - connector, - source, - mu_map, + connector.configure_voxel_phantom( + source=source, + mu_map=mu_map, voxel_size_mm=4.0, scoring_routine=1, ) - write_windows(connector, [75.0], [225.0], [0]) + connector.add_config_value(1, 140.0) + connector.add_config_value(19, 2) + connector.add_config_value(53, 0) + connector.set_energy_windows([75.0], [225.0], [0]) add_standard_runtime(connector, photon_multiplier=1, seed=seed) outputs = connector.run() return outputs["tot_w1"].projection diff --git a/examples/03_multi_window.py b/examples/03_multi_window.py index b6b6585..996a85b 100644 --- a/examples/03_multi_window.py +++ b/examples/03_multi_window.py @@ -12,10 +12,8 @@ from _python_connector_helpers import ( add_standard_runtime, build_small_phantom_zyx, - configure_voxel_input, projection_view0, require_simind, - write_windows, ) from sirf_simind_connection import SimindPythonConnector, configs @@ -35,20 +33,21 @@ def main() -> None: ) source, mu_map = build_small_phantom_zyx() - configure_voxel_input( - connector, - source, - mu_map, + connector.configure_voxel_phantom( + source=source, + mu_map=mu_map, voxel_size_mm=4.0, scoring_routine=1, ) + connector.add_config_value(19, 2) + connector.add_config_value(53, 0) # Lu-177 TEW around the 208 keV peak: # lower scatter 166-187, photopeak 187-229, upper scatter 229-250 keV. lowers = [166.0, 187.0, 229.0] uppers = [187.0, 229.0, 250.0] orders = [0, 0, 0] - write_windows(connector, lowers, uppers, orders) + connector.set_energy_windows(lowers, uppers, orders) connector.add_config_value(1, 208.0) add_standard_runtime(connector, photon_multiplier=1, seed=12345, nuclide="lu177") diff --git a/examples/04_custom_config.py b/examples/04_custom_config.py index 896df91..4b850da 100644 --- a/examples/04_custom_config.py +++ b/examples/04_custom_config.py @@ -143,9 +143,9 @@ def demonstrate_yaml_workflow(): print(f"\nAll configurations saved to: {output_dir}") print("\nYou can now:") print("1. Edit the YAML files directly") - print("2. Use them with SimindSimulator:") - print(" config = SimulationConfig('lehr_collimator.yaml')") - print(" simulator = SimindSimulator(config_source=config, ...)") + print("2. Use them with SimindPythonConnector:") + print(" connector = SimindPythonConnector(") + print(" config_source='lehr_collimator.yaml', ...") def demonstrate_new_api_usage(): @@ -158,40 +158,21 @@ def demonstrate_new_api_usage(): # Create a custom config custom_config = create_custom_lehr_config() - # Show how it would be used with the new SimindSimulator API + # Show how it would be used with the connector-first API print("\nWith the new API, you would use this config like:") print( """ - from sirf_simind_connection import SimindSimulator - from sirf_simind_connection.core import ScoringRoutine + from sirf_simind_connection import SimindPythonConnector - # Option 1: Use the config object directly - simulator = SimindSimulator( - config_source=custom_config, # Pass the config object + connector = SimindPythonConnector( + config_source='lehr_collimator.yaml', output_dir='output', - output_prefix='lehr_sim', - photon_multiplier=10, - scoring_routine=ScoringRoutine.SCATTWIN + output_prefix='lehr_sim' ) - # Set your inputs - simulator.set_source(phantom) - simulator.set_mu_map(mu_map) - simulator.set_energy_windows([126], [154], [0]) - - # Run simulation - simulator.run_simulation() - - ---------- - - # Option 2: Use a saved YAML file - simulator = SimindSimulator( - config_source='lehr_collimator.yaml', # Pass YAML path - output_dir='output', - output_prefix='lehr_sim', - photon_multiplier=10, - scoring_routine=ScoringRoutine.SCATTWIN - ) + # Configure runtime/config switches and run + connector.add_runtime_switch('NN', 10) + outputs = connector.run() """ ) diff --git a/examples/05_scattwin_vs_penetrate_comparison.py b/examples/05_scattwin_vs_penetrate_comparison.py index 34a93cd..fec116f 100644 --- a/examples/05_scattwin_vs_penetrate_comparison.py +++ b/examples/05_scattwin_vs_penetrate_comparison.py @@ -13,10 +13,8 @@ from _python_connector_helpers import ( add_standard_runtime, build_small_phantom_zyx, - configure_voxel_input, projection_view0, require_simind, - write_windows, ) from sirf_simind_connection import SimindPythonConnector, configs @@ -35,18 +33,18 @@ def _run_case( output_prefix=prefix, quantization_scale=0.05, ) - configure_voxel_input( - connector, - source, - mu_map, + connector.configure_voxel_phantom( + source=source, + mu_map=mu_map, voxel_size_mm=4.0, scoring_routine=scoring_routine, ) + connector.add_config_value(1, 140.0) # Use full detector-hit acceptance for both SCATTWIN and PENETRATE in this # comparison example. connector.add_config_value(19, -90) connector.add_config_value(53, 1) - write_windows(connector, [75.0], [225.0], [0]) + connector.set_energy_windows([75.0], [225.0], [0]) add_standard_runtime(connector, photon_multiplier=1, seed=12345) results = connector.run() diff --git a/examples/07A_stir_adaptor_osem.py b/examples/07A_stir_adaptor_osem.py index 3e1c629..944ebfc 100644 --- a/examples/07A_stir_adaptor_osem.py +++ b/examples/07A_stir_adaptor_osem.py @@ -121,11 +121,13 @@ def _run_stir_osem( return initial -def _projection_plane(projection_array: np.ndarray) -> np.ndarray: +def _projection_plane(projection_array: np.ndarray, view_index: int = 0) -> np.ndarray: if projection_array.ndim == 4: - return projection_array[0, :, 0, :] + # [tof, axial, view, tangential] + return projection_array[0, :, view_index, :] if projection_array.ndim == 3: - return projection_array[0, :, :] + # [axial, view, tangential] when tof axis is squeezed + return projection_array[:, view_index, :] return projection_array diff --git a/examples/07B_sirf_adaptor_osem.py b/examples/07B_sirf_adaptor_osem.py index 9c0cdea..f825e6e 100644 --- a/examples/07B_sirf_adaptor_osem.py +++ b/examples/07B_sirf_adaptor_osem.py @@ -103,11 +103,13 @@ def _run_sirf_osem( return initial -def _projection_plane(projection_array: np.ndarray) -> np.ndarray: +def _projection_plane(projection_array: np.ndarray, view_index: int = 0) -> np.ndarray: if projection_array.ndim == 4: - return projection_array[0, :, 0, :] + # [tof, axial, view, tangential] + return projection_array[0, :, view_index, :] if projection_array.ndim == 3: - return projection_array[0, :, :] + # [axial, view, tangential] when tof axis is squeezed + return projection_array[:, view_index, :] return projection_array diff --git a/examples/07C_pytomography_adaptor_osem.py b/examples/07C_pytomography_adaptor_osem.py index f3e7b10..d91dffa 100644 --- a/examples/07C_pytomography_adaptor_osem.py +++ b/examples/07C_pytomography_adaptor_osem.py @@ -15,6 +15,12 @@ import numpy as np import torch from pytomography import algorithms, likelihoods +from pytomography.io.SPECT import simind as pytomo_simind +from pytomography.projectors.SPECT import SPECTSystemMatrix +from pytomography.transforms.SPECT import ( + SPECTAttenuationTransform, + SPECTPSFTransform, +) from sirf_simind_connection import PyTomographySimindAdaptor, configs @@ -36,12 +42,16 @@ def _build_small_tensors() -> tuple[torch.Tensor, torch.Tensor]: return torch.from_numpy(source), torch.from_numpy(mu_map) -def _projection_plane(projection_array: np.ndarray) -> np.ndarray: +def _projection_plane(projection_array: np.ndarray, view_index: int = 0) -> np.ndarray: if projection_array.ndim == 4: - return projection_array[0, 0, :, :] + # Fallback path shape from raw SIMIND conversion: + # [tof, axial, view, tangential] -> display [axial, tangential] + return projection_array[0, :, view_index, :] if projection_array.ndim == 3: - return projection_array[0, :, :] - return projection_array + # PyTomography projection tensors are [view, radial, axial]. + # Transpose for display so rows are axial and columns are radial. + return projection_array[view_index, :, :].T + return projection_array.T if projection_array.ndim == 2 else projection_array def _save_summary_plot( @@ -54,9 +64,11 @@ def _save_summary_plot( proj_arr = projection_tensor.detach().cpu().numpy() recon_arr = reconstruction.detach().cpu().numpy() - source_slice = source_arr[:, :, source_arr.shape[2] // 2] + # PyTomography object tensors are (x, y, z); transpose for display so + # imshow columns follow x and rows follow y (matching STIR/SIRF summaries). + source_slice = source_arr[:, :, source_arr.shape[2] // 2].T proj_slice = _projection_plane(proj_arr) - recon_slice = recon_arr[:, :, recon_arr.shape[2] // 2] + recon_slice = recon_arr[:, :, recon_arr.shape[2] // 2].T fig, axes = plt.subplots(1, 3, figsize=(12, 4)) axes[0].imshow(source_slice, cmap="viridis") @@ -72,6 +84,36 @@ def _save_summary_plot( plt.close(fig) +def _build_system_matrix( + projection_header: Path, + attenuation_map_xyz: torch.Tensor, +) -> SPECTSystemMatrix: + object_meta, proj_meta = pytomo_simind.get_metadata(str(projection_header)) + + obj2obj_transforms = [] + try: + obj2obj_transforms.append( + SPECTAttenuationTransform( + attenuation_map=attenuation_map_xyz.to(dtype=torch.float32).contiguous() + ) + ) + except Exception: + pass + + try: + psf_meta = pytomo_simind.get_psfmeta_from_header(str(projection_header)) + obj2obj_transforms.append(SPECTPSFTransform(psf_meta=psf_meta)) + except Exception: + pass + + return SPECTSystemMatrix( + obj2obj_transforms=obj2obj_transforms, + proj2proj_transforms=[], + object_meta=object_meta, + proj_meta=proj_meta, + ) + + def main() -> None: if shutil.which("simind") is None: raise RuntimeError( @@ -111,12 +153,11 @@ def main() -> None: f"got {tuple(projection_tensor.shape)}" ) - system_matrix = adaptor.build_system_matrix( - key="tot_w1", - use_psf=True, - use_attenuation=True, - ) total_h00_path = adaptor.get_output_header_path("tot_w1") + system_matrix = _build_system_matrix( + projection_header=total_h00_path, + attenuation_map_xyz=mu_tensor, + ) likelihood = likelihoods.PoissonLogLikelihood( system_matrix=system_matrix, projections=projection_tensor, diff --git a/examples/_python_connector_helpers.py b/examples/_python_connector_helpers.py index c0ecad6..8c83328 100644 --- a/examples/_python_connector_helpers.py +++ b/examples/_python_connector_helpers.py @@ -1,14 +1,10 @@ from __future__ import annotations import shutil -from pathlib import Path import numpy as np from sirf_simind_connection import SimindPythonConnector -from sirf_simind_connection.converters.attenuation import attenuation_to_density -from sirf_simind_connection.core.types import MAX_SOURCE, SIMIND_VOXEL_UNIT_CONVERSION -from sirf_simind_connection.utils.simind_utils import create_window_file def require_simind() -> None: @@ -35,89 +31,6 @@ def build_small_phantom_zyx() -> tuple[np.ndarray, np.ndarray]: return source, mu_map -def configure_voxel_input( - connector: SimindPythonConnector, - source: np.ndarray, - mu_map: np.ndarray, - voxel_size_mm: float = 4.0, - scoring_routine: int = 1, -) -> tuple[Path, Path]: - cfg = connector.get_config() - dim_z, dim_y, dim_x = (int(v) for v in source.shape) - vox_cm = voxel_size_mm / SIMIND_VOXEL_UNIT_CONVERSION - - cfg.set_flag(5, True) - cfg.set_value(15, -1) - cfg.set_value(14, -1) - cfg.set_flag(14, True) - cfg.set_value(84, int(scoring_routine)) - - cfg.set_value(2, dim_z * vox_cm / 2.0) - cfg.set_value(3, dim_x * vox_cm / 2.0) - cfg.set_value(4, dim_y * vox_cm / 2.0) - cfg.set_value(28, vox_cm) - cfg.set_value(76, dim_x) - cfg.set_value(77, dim_y) - - cfg.set_value(5, dim_z * vox_cm / 2.0) - cfg.set_value(6, dim_x * vox_cm / 2.0) - cfg.set_value(7, dim_y * vox_cm / 2.0) - cfg.set_value(31, vox_cm) - cfg.set_value(33, 1) - cfg.set_value(34, dim_z) - cfg.set_value(78, dim_x) - cfg.set_value(79, dim_y) - - connector.add_runtime_switch("PX", vox_cm) - - source_max = float(source.max()) - if source_max > 0: - source_scaled = ( - source / source_max * (MAX_SOURCE * float(connector.quantization_scale)) - ) - else: - source_scaled = np.zeros_like(source) - source_u16 = np.clip(np.round(source_scaled), 0, MAX_SOURCE).astype(np.uint16) - - src_prefix = f"{connector.output_prefix}_src" - source_path = connector.output_dir / f"{src_prefix}.smi" - source_u16.tofile(source_path) - cfg.set_data_file(6, src_prefix) - - photon_energy = float(cfg.get_value("photon_energy")) - if cfg.get_flag(11): - density = attenuation_to_density(mu_map, photon_energy) * 1000.0 - else: - density = np.zeros_like(mu_map) - density_u16 = np.clip(np.round(density), 0, np.iinfo(np.uint16).max).astype( - np.uint16 - ) - - dns_prefix = f"{connector.output_prefix}_dns" - density_path = connector.output_dir / f"{dns_prefix}.dmi" - density_u16.tofile(density_path) - cfg.set_data_file(5, dns_prefix) - - connector.add_config_value(1, 140.0) - connector.add_config_value(19, 2) - connector.add_config_value(53, 0) - return source_path, density_path - - -def write_windows( - connector: SimindPythonConnector, - lowers: list[float], - uppers: list[float], - orders: list[int], -) -> None: - create_window_file( - lowers, - uppers, - orders, - output_filename=str(connector.output_dir / connector.output_prefix), - ) - - def add_standard_runtime( connector: SimindPythonConnector, photon_multiplier: int = 1, diff --git a/pyproject.toml b/pyproject.toml index 8a562e3..2b654fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,12 +3,12 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "sirf-simind-connection" -version = "0.4.0" -description = "A wrapper for SIRF and SIMIND for Monte Carlo SPECT simulations" +name = "py-smc" +version = "0.5.0" +description = "Python SIMIND Monte Carlo connector with STIR/SIRF/PyTomography adaptors" readme = "README.md" requires-python = ">=3.9" -license = {text = "Apache-2.0"} +license = "Apache-2.0" authors = [ {name = "Sam Porter", email = "sam.porter.18@ucl.ac.uk"}, {name = "Rebecca Gillen", email = "rebecca.gillen.18@ucl.ac.uk"}, @@ -21,7 +21,6 @@ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering :: Medical Science Apps.", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/sirf_simind_connection/__init__.py b/sirf_simind_connection/__init__.py index 5c1216b..2f837e9 100644 --- a/sirf_simind_connection/__init__.py +++ b/sirf_simind_connection/__init__.py @@ -1,7 +1,5 @@ """ -SIRF ⇄ SIMIND connector/adaptor API. - ->>> from sirf_simind_connection import SimindSimulator +py-smc connector/adaptor API. """ import importlib @@ -9,10 +7,14 @@ from typing import Any -try: # installed (pip/poetry) - __version__ = _meta.version(__name__) -except _meta.PackageNotFoundError: # editable / source checkout - __version__ = "0.4.0" +for _dist_name in ("py-smc", "sirf-simind-connection", __name__): + try: # installed (pip/poetry) + __version__ = _meta.version(_dist_name) + break + except _meta.PackageNotFoundError: + continue +else: # editable / source checkout + __version__ = "0.5.0" def __getattr__(name: str) -> Any: @@ -28,14 +30,13 @@ def __getattr__(name: str) -> Any: mod = importlib.import_module(f".{name}", __name__) globals()[name] = mod return mod - elif name in {"SimindSimulator", "SimulationConfig"}: + elif name in {"SimulationConfig"}: core = importlib.import_module(".core", __name__) obj = getattr(core, name) globals()[name] = obj return obj elif name in { "BaseConnector", - "NativeBackendConnector", "NumpyConnector", "PyTomographySimindAdaptor", "ProjectionResult", @@ -53,7 +54,6 @@ def __getattr__(name: str) -> Any: __all__ = [ "BaseConnector", - "NativeBackendConnector", "NumpyConnector", "ProjectionResult", "PyTomographySimindAdaptor", @@ -61,7 +61,6 @@ def __getattr__(name: str) -> Any: "SirfSimindAdaptor", "SimindPythonConnector", "SimulationConfig", - "SimindSimulator", "StirSimindAdaptor", "builders", "configs", diff --git a/sirf_simind_connection/configs/Example.yaml b/sirf_simind_connection/configs/Example.yaml index 6dcd926..2788087 100644 --- a/sirf_simind_connection/configs/Example.yaml +++ b/sirf_simind_connection/configs/Example.yaml @@ -276,7 +276,7 @@ parameters: description: Pixel size for simulated images (cm) - affects resolution vs FOV spect_no_projections: index: 29 - value: 6.0 + value: 24.0 description: Number of SPECT projections (60, 120, 180 typical) spect_rotation: index: 30 diff --git a/sirf_simind_connection/connectors/__init__.py b/sirf_simind_connection/connectors/__init__.py index 91fa1de..8169312 100644 --- a/sirf_simind_connection/connectors/__init__.py +++ b/sirf_simind_connection/connectors/__init__.py @@ -1,8 +1,6 @@ -""" -Connector/adaptor APIs layered over the SIMIND execution core. -""" +"""Connector/adaptor APIs.""" -from .base import BaseConnector, NativeBackendConnector +from .base import BaseConnector from .python_connector import ( NumpyConnector, ProjectionResult, @@ -16,7 +14,6 @@ __all__ = [ "BaseConnector", - "NativeBackendConnector", "NumpyConnector", "PyTomographySimindAdaptor", "ProjectionResult", diff --git a/sirf_simind_connection/connectors/_spacing.py b/sirf_simind_connection/connectors/_spacing.py new file mode 100644 index 0000000..1f11818 --- /dev/null +++ b/sirf_simind_connection/connectors/_spacing.py @@ -0,0 +1,69 @@ +"""Helpers for extracting voxel spacing from SIRF/STIR image objects.""" + +from __future__ import annotations + +from typing import Any + + +def _call_or_value(value: Any) -> Any: + return value() if callable(value) else value + + +def _voxel_size_from_spacing(spacing: Any) -> float | None: + try: + values = tuple(float(v) for v in spacing) + except Exception: + values = () + + if values: + if len(values) >= 4: + return float(values[3]) + if len(values) >= 3: + return float(values[2]) + + if hasattr(spacing, "z"): + try: + return float(_call_or_value(getattr(spacing, "z"))) + except Exception: + pass + + if hasattr(spacing, "at"): + for index in (3, 2): + try: + return float(spacing.at(index)) + except Exception: + continue + + for index in (3, 2): + try: + return float(spacing[index]) + except Exception: + continue + + return None + + +def extract_voxel_size_mm(image: Any, backend_name: str) -> float: + """Extract z voxel spacing in mm from backend image metadata.""" + if hasattr(image, "voxel_sizes"): + voxel_sizes = image.voxel_sizes() + try: + if len(voxel_sizes) >= 3: + return float(voxel_sizes[2]) + except Exception: + pass + + if hasattr(image, "get_grid_spacing"): + voxel_size_mm = _voxel_size_from_spacing(image.get_grid_spacing()) + if voxel_size_mm is not None: + return voxel_size_mm + raise ValueError( + f"Unable to read voxel spacing from {backend_name} get_grid_spacing()." + ) + + raise ValueError( + f"{backend_name} source object must expose voxel_sizes() or get_grid_spacing()." + ) + + +__all__ = ["extract_voxel_size_mm"] diff --git a/sirf_simind_connection/connectors/base.py b/sirf_simind_connection/connectors/base.py index df35241..dcb38a7 100644 --- a/sirf_simind_connection/connectors/base.py +++ b/sirf_simind_connection/connectors/base.py @@ -1,17 +1,15 @@ -""" -Abstract connector contracts and shared native-backend connector logic. -""" +"""Abstract connector contract shared by all connector/adaptor implementations.""" + +from __future__ import annotations from abc import ABC, abstractmethod -from typing import Any, Dict, Mapping, Union +from typing import Any, Dict, Mapping -from sirf_simind_connection.backends import set_backend from sirf_simind_connection.core.config import SimulationConfig -from sirf_simind_connection.core.types import ScoringRoutine class BaseConnector(ABC): - """Minimal contract shared by all connector types.""" + """Minimal interface for all SIMIND connector styles.""" @abstractmethod def add_config_value(self, index: int, value: Any) -> None: @@ -22,103 +20,21 @@ def add_runtime_switch(self, switch: str, value: Any) -> None: """Set one runtime switch.""" def set_runtime_switches(self, switches: Mapping[str, Any]) -> None: - """Set many runtime switches.""" + """Set multiple runtime switches.""" for switch, value in switches.items(): self.add_runtime_switch(switch, value) @abstractmethod - def run(self) -> Any: - """Execute the simulation.""" + def run(self, *args: Any, **kwargs: Any) -> Any: + """Execute simulation and return connector-native outputs.""" @abstractmethod def get_outputs(self) -> Dict[str, Any]: - """Return simulation outputs in connector-native types.""" + """Return outputs from the latest successful run.""" @abstractmethod def get_config(self) -> SimulationConfig: - """Return the active simulation config object.""" - - -class NativeBackendConnector(BaseConnector): - """Shared implementation for SIRF/STIR-backed connectors.""" - - def __init__( - self, - config_source: Union[str, SimulationConfig], - output_dir: str, - backend: str, - output_prefix: str = "output", - photon_multiplier: int = 1, - quantization_scale: float = 1.0, - scoring_routine: Union[ScoringRoutine, int] = ScoringRoutine.SCATTWIN, - ) -> None: - set_backend(backend) - self.backend = backend - from sirf_simind_connection.core.simulator import SimindSimulator - - self.simulator = SimindSimulator( - config_source=config_source, - output_dir=output_dir, - output_prefix=output_prefix, - photon_multiplier=photon_multiplier, - quantization_scale=quantization_scale, - scoring_routine=scoring_routine, - ) - - def set_source(self, source: Any) -> None: - self.simulator.set_source(source) - - def set_mu_map(self, mu_map: Any) -> None: - self.simulator.set_mu_map(mu_map) - - def set_template_sinogram(self, template_sinogram: Any) -> None: - self.simulator.set_template_sinogram(template_sinogram) - - def set_energy_windows( - self, lower_bounds: Any, upper_bounds: Any, scatter_orders: Any - ) -> None: - self.simulator.set_energy_windows(lower_bounds, upper_bounds, scatter_orders) - - def add_config_value(self, index: int, value: Any) -> None: - self.simulator.add_config_value(index, value) - - def add_runtime_switch(self, switch: str, value: Any) -> None: - self.simulator.add_runtime_switch(switch, value) - - def run(self) -> None: - self.simulator.run_simulation() - - def get_outputs(self) -> Dict[str, Any]: - return self.simulator.get_outputs(native=True, preferred_backend=self.backend) - - def get_total_output(self, window: int = 1) -> Any: - return self.simulator.get_total_output( - window=window, native=True, preferred_backend=self.backend - ) - - def get_scatter_output(self, window: int = 1) -> Any: - return self.simulator.get_scatter_output( - window=window, native=True, preferred_backend=self.backend - ) - - def get_primary_output(self, window: int = 1) -> Any: - return self.simulator.get_primary_output( - window=window, native=True, preferred_backend=self.backend - ) - - def get_penetrate_output(self, component: Any) -> Any: - return self.simulator.get_penetrate_output( - component=component, native=True, preferred_backend=self.backend - ) - - def list_available_outputs(self) -> list[str]: - return self.simulator.list_available_outputs() - - def get_scoring_routine(self) -> ScoringRoutine: - return self.simulator.get_scoring_routine() - - def get_config(self) -> SimulationConfig: - return self.simulator.get_config() + """Return active simulation configuration.""" -__all__ = ["BaseConnector", "NativeBackendConnector"] +__all__ = ["BaseConnector"] diff --git a/sirf_simind_connection/connectors/python_connector.py b/sirf_simind_connection/connectors/python_connector.py index a26462d..e00c6f2 100644 --- a/sirf_simind_connection/connectors/python_connector.py +++ b/sirf_simind_connection/connectors/python_connector.py @@ -15,11 +15,18 @@ import numpy as np from sirf_simind_connection.connectors.base import BaseConnector +from sirf_simind_connection.converters.attenuation import attenuation_to_density from sirf_simind_connection.converters.simind_to_stir import SimindToStirConverter -from sirf_simind_connection.core.components import SimindExecutor from sirf_simind_connection.core.config import RuntimeSwitches, SimulationConfig -from sirf_simind_connection.core.types import PenetrateOutputType, ScoringRoutine +from sirf_simind_connection.core.executor import SimindExecutor +from sirf_simind_connection.core.types import ( + MAX_SOURCE, + SIMIND_VOXEL_UNIT_CONVERSION, + PenetrateOutputType, + ScoringRoutine, +) from sirf_simind_connection.utils.interfile_numpy import load_interfile_array +from sirf_simind_connection.utils.simind_utils import create_window_file ConfigSource = Union[str, os.PathLike[str], SimulationConfig] @@ -95,6 +102,109 @@ def add_config_value(self, index: int, value: Any) -> None: """Set a SIMIND config value.""" self.config.set_value(index, value) + def configure_voxel_phantom( + self, + source: np.ndarray, + mu_map: np.ndarray, + voxel_size_mm: float = 4.0, + scoring_routine: Union[ScoringRoutine, int] = ScoringRoutine.SCATTWIN, + ) -> tuple[Path, Path]: + """ + Configure voxel geometry and write source/density input files. + + Returns: + Tuple of (source_file_path, density_file_path). + """ + source_array = np.asarray(source, dtype=np.float32) + mu_map_array = np.asarray(mu_map, dtype=np.float32) + + if source_array.ndim != 3 or mu_map_array.ndim != 3: + raise ValueError("source and mu_map must both be 3D arrays") + if source_array.shape != mu_map_array.shape: + raise ValueError("source and mu_map must have identical shapes") + + vox_cm = float(voxel_size_mm) / SIMIND_VOXEL_UNIT_CONVERSION + if vox_cm <= 0: + raise ValueError("voxel_size_mm must be > 0") + + routine = ( + ScoringRoutine(scoring_routine) + if isinstance(scoring_routine, int) + else scoring_routine + ) + dim_z, dim_y, dim_x = (int(v) for v in source_array.shape) + + cfg = self.config + cfg.set_flag(5, True) + cfg.set_value(15, -1) + cfg.set_value(14, -1) + cfg.set_flag(14, True) + cfg.set_value(84, routine.value) + + # Source geometry + cfg.set_value(2, dim_z * vox_cm / 2.0) + cfg.set_value(3, dim_x * vox_cm / 2.0) + cfg.set_value(4, dim_y * vox_cm / 2.0) + cfg.set_value(28, vox_cm) + cfg.set_value(76, dim_x) + cfg.set_value(77, dim_y) + + # Density geometry + cfg.set_value(5, dim_z * vox_cm / 2.0) + cfg.set_value(6, dim_x * vox_cm / 2.0) + cfg.set_value(7, dim_y * vox_cm / 2.0) + cfg.set_value(31, vox_cm) + cfg.set_value(33, 1) + cfg.set_value(34, dim_z) + cfg.set_value(78, dim_x) + cfg.set_value(79, dim_y) + + self.runtime_switches.set_switch("PX", vox_cm) + + source_max = float(source_array.max()) + if source_max > 0: + source_scaled = ( + source_array / source_max * (MAX_SOURCE * self.quantization_scale) + ) + else: + source_scaled = np.zeros_like(source_array) + source_u16 = np.clip(np.round(source_scaled), 0, MAX_SOURCE).astype(np.uint16) + + src_prefix = f"{self.output_prefix}_src" + source_path = self.output_dir / f"{src_prefix}.smi" + source_u16.tofile(source_path) + cfg.set_data_file(6, src_prefix) + + if cfg.get_flag(11): + photon_energy = float(cfg.get_value("photon_energy")) + density = attenuation_to_density(mu_map_array, photon_energy) * 1000.0 + else: + density = np.zeros_like(mu_map_array) + + density_u16 = np.clip(np.round(density), 0, np.iinfo(np.uint16).max).astype( + np.uint16 + ) + dns_prefix = f"{self.output_prefix}_dns" + density_path = self.output_dir / f"{dns_prefix}.dmi" + density_u16.tofile(density_path) + cfg.set_data_file(5, dns_prefix) + + return source_path, density_path + + def set_energy_windows( + self, + lower_bounds: Union[float, list[float]], + upper_bounds: Union[float, list[float]], + scatter_orders: Union[int, list[int]], + ) -> None: + """Write a SIMIND window file for this connector run.""" + create_window_file( + lower_bounds, + upper_bounds, + scatter_orders, + output_filename=str(self.output_dir / self.output_prefix), + ) + def run( self, runtime_operator: Optional[RuntimeOperator] = None ) -> dict[str, ProjectionResult]: diff --git a/sirf_simind_connection/connectors/pytomography_adaptor.py b/sirf_simind_connection/connectors/pytomography_adaptor.py index e107e13..286e19b 100644 --- a/sirf_simind_connection/connectors/pytomography_adaptor.py +++ b/sirf_simind_connection/connectors/pytomography_adaptor.py @@ -20,14 +20,10 @@ RuntimeOperator, SimindPythonConnector, ) -from sirf_simind_connection.converters.attenuation import attenuation_to_density from sirf_simind_connection.core.types import ( - MAX_SOURCE, - SIMIND_VOXEL_UNIT_CONVERSION, ScoringRoutine, ValidationError, ) -from sirf_simind_connection.utils.simind_utils import create_window_file try: @@ -37,16 +33,8 @@ try: # pragma: no cover - optional dependency from pytomography.io.SPECT import simind as pytomo_simind - from pytomography.projectors.SPECT import SPECTSystemMatrix - from pytomography.transforms.SPECT import ( - SPECTAttenuationTransform, - SPECTPSFTransform, - ) except ImportError: # pragma: no cover - optional dependency pytomo_simind = None # type: ignore[assignment] - SPECTSystemMatrix = None # type: ignore[assignment] - SPECTAttenuationTransform = None # type: ignore[assignment] - SPECTPSFTransform = None # type: ignore[assignment] PathLike = Union[str, os.PathLike[str]] @@ -84,6 +72,14 @@ def __init__( self.output_dir = Path(output_dir).expanduser().resolve() self.output_prefix = output_prefix self.voxel_size_mm = float(voxel_size_mm) + if self.voxel_size_mm <= 0: + raise ValueError("voxel_size_mm must be > 0") + + self._scoring_routine = ( + ScoringRoutine(scoring_routine) + if isinstance(scoring_routine, int) + else scoring_routine + ) self._source: Optional[torch.Tensor] = None self._mu_map: Optional[torch.Tensor] = None @@ -95,22 +91,6 @@ def __init__( self._output_header_paths: Optional[dict[str, Path]] = None self.add_runtime_switch("NN", photon_multiplier) - self._configure_voxel_phantom_defaults(scoring_routine) - - def _configure_voxel_phantom_defaults( - self, scoring_routine: Union[ScoringRoutine, int] - ) -> None: - cfg = self.python_connector.get_config() - cfg.set_flag(5, True) # SPECT study - cfg.set_value(15, -1) # voxel source - cfg.set_value(14, -1) # voxel phantom - cfg.set_flag(14, True) # write interfile headers - - if isinstance(scoring_routine, int): - routine_value = ScoringRoutine(scoring_routine).value - else: - routine_value = scoring_routine.value - cfg.set_value(84, routine_value) def set_source(self, source: torch.Tensor) -> None: self._source = self._validate_tensor(source, name="source") @@ -159,9 +139,15 @@ def run( assert self._mu_map is not None assert self._energy_windows is not None - self._configure_geometry(self._source) - self._write_input_maps(self._source, self._mu_map) - self._write_window_file(*self._energy_windows) + source_zyx = self.to_simind_image_axes(self._source).numpy() + mu_map_zyx = self.to_simind_image_axes(self._mu_map).numpy() + self.python_connector.configure_voxel_phantom( + source=source_zyx, + mu_map=mu_map_zyx, + voxel_size_mm=self.voxel_size_mm, + scoring_routine=self._scoring_routine, + ) + self.python_connector.set_energy_windows(*self._energy_windows) raw_outputs = self.python_connector.run(runtime_operator=runtime_operator) outputs: dict[str, torch.Tensor] = {} @@ -211,51 +197,6 @@ def get_output_header_path(self, key: str) -> Path: raise KeyError(f"Unknown output key {key!r}. Available: {available}") return self._output_header_paths[key] - def get_pytomography_metadata(self, key: str = "tot_w1") -> tuple[Any, Any]: - if pytomo_simind is None: - raise ImportError( - "pytomography is required for get_pytomography_metadata()." - ) - header_path = self.get_output_header_path(key) - return pytomo_simind.get_metadata(str(header_path)) - - def build_system_matrix( - self, key: str = "tot_w1", use_psf: bool = True, use_attenuation: bool = True - ) -> Any: - if pytomo_simind is None or SPECTSystemMatrix is None: - raise ImportError("pytomography is required for build_system_matrix().") - header_path = self.get_output_header_path(key) - object_meta, proj_meta = pytomo_simind.get_metadata(str(header_path)) - - obj2obj_transforms = [] - if use_attenuation and SPECTAttenuationTransform is not None: - try: - if self._mu_map is not None: - attenuation_map = self._mu_map.to(dtype=torch.float32).contiguous() - else: - attenuation_map = pytomo_simind.get_attenuation_map( - str(header_path) - ) - obj2obj_transforms.append( - SPECTAttenuationTransform(attenuation_map=attenuation_map) - ) - except Exception: - pass - - if use_psf and SPECTPSFTransform is not None: - try: - psf_meta = pytomo_simind.get_psfmeta_from_header(str(header_path)) - obj2obj_transforms.append(SPECTPSFTransform(psf_meta=psf_meta)) - except Exception: - pass - - return SPECTSystemMatrix( - obj2obj_transforms=obj2obj_transforms, - proj2proj_transforms=[], - object_meta=object_meta, - proj_meta=proj_meta, - ) - def get_total_output(self, window: int = 1) -> torch.Tensor: return self._get_component("tot", window) @@ -298,31 +239,6 @@ def _validate_tensor(value: torch.Tensor, name: str) -> torch.Tensor: ) return value.detach().cpu().to(dtype=torch.float32).contiguous() - def _configure_geometry(self, source: torch.Tensor) -> None: - cfg = self.python_connector.get_config() - dim_x, dim_y, dim_z = (int(v) for v in source.shape) - vox_cm = self.voxel_size_mm / SIMIND_VOXEL_UNIT_CONVERSION - - # Source geometry - cfg.set_value(2, dim_z * vox_cm / 2.0) - cfg.set_value(3, dim_x * vox_cm / 2.0) - cfg.set_value(4, dim_y * vox_cm / 2.0) - cfg.set_value(28, vox_cm) - cfg.set_value(76, dim_x) - cfg.set_value(77, dim_y) - - # Density geometry - cfg.set_value(5, dim_z * vox_cm / 2.0) - cfg.set_value(6, dim_x * vox_cm / 2.0) - cfg.set_value(7, dim_y * vox_cm / 2.0) - cfg.set_value(31, vox_cm) - cfg.set_value(33, 1) - cfg.set_value(34, dim_z) - cfg.set_value(78, dim_x) - cfg.set_value(79, dim_y) - - self.add_runtime_switch("PX", vox_cm) - @staticmethod def from_simind_image_axes(value: torch.Tensor) -> torch.Tensor: """Convert SIMIND image order ``(z, y, x)`` to PyTomography ``(x, y, z)``.""" @@ -343,48 +259,5 @@ def to_simind_image_axes(value: torch.Tensor) -> torch.Tensor: ) return value.permute(2, 1, 0).contiguous().to(dtype=torch.float32) - def _write_input_maps(self, source: torch.Tensor, mu_map: torch.Tensor) -> None: - cfg = self.python_connector.get_config() - - source_np = self.to_simind_image_axes(source).numpy() - source_max = float(source_np.max()) - if source_max > 0: - source_np = ( - source_np - / source_max - * (MAX_SOURCE * float(self.python_connector.quantization_scale)) - ) - source_u16 = np.clip(np.round(source_np), 0, MAX_SOURCE).astype(np.uint16) - - src_prefix = f"{self.output_prefix}_src" - src_path = self.output_dir / f"{src_prefix}.smi" - source_u16.tofile(src_path) - cfg.set_data_file(6, src_prefix) - - mu_np = self.to_simind_image_axes(mu_map).numpy().astype(np.float32, copy=False) - photon_energy = float(cfg.get_value("photon_energy")) - use_attenuation = bool(cfg.get_flag(11)) - if use_attenuation: - density = attenuation_to_density(mu_np, photon_energy) * 1000.0 - else: - density = np.zeros_like(mu_np) - density_u16 = np.clip(np.round(density), 0, np.iinfo(np.uint16).max).astype( - np.uint16 - ) - - dns_prefix = f"{self.output_prefix}_dns" - dns_path = self.output_dir / f"{dns_prefix}.dmi" - density_u16.tofile(dns_path) - cfg.set_data_file(5, dns_prefix) - - def _write_window_file( - self, - lower_bounds: list[float], - upper_bounds: list[float], - scatter_orders: list[int], - ) -> None: - window_path = str(self.output_dir / self.output_prefix) - create_window_file(lower_bounds, upper_bounds, scatter_orders, window_path) - __all__ = ["PyTomographySimindAdaptor", "RuntimeOperator"] diff --git a/sirf_simind_connection/connectors/sirf_adaptor.py b/sirf_simind_connection/connectors/sirf_adaptor.py index 82ae982..e042e3d 100644 --- a/sirf_simind_connection/connectors/sirf_adaptor.py +++ b/sirf_simind_connection/connectors/sirf_adaptor.py @@ -1,37 +1,164 @@ -""" -SIRF/SIMIND adaptor facade. -""" +"""SIRF adaptor implemented on top of the connector-first NumPy pipeline.""" from __future__ import annotations -from typing import Union +from typing import Any, Dict, Optional -from sirf_simind_connection.connectors.base import NativeBackendConnector +import numpy as np + +from sirf_simind_connection.connectors._spacing import extract_voxel_size_mm +from sirf_simind_connection.connectors.base import BaseConnector +from sirf_simind_connection.connectors.python_connector import ( + ConfigSource, + RuntimeOperator, + SimindPythonConnector, +) from sirf_simind_connection.core.config import SimulationConfig -from sirf_simind_connection.core.types import ScoringRoutine +from sirf_simind_connection.core.types import PenetrateOutputType, ScoringRoutine +from sirf_simind_connection.utils import get_array + +try: + import sirf.STIR as sirf +except ImportError: # pragma: no cover - optional dependency + sirf = None # type: ignore[assignment] -class SirfSimindAdaptor(NativeBackendConnector): - """Adaptor that consumes and returns SIRF-native data objects.""" + +class SirfSimindAdaptor(BaseConnector): + """Adaptor consuming/returning SIRF-native objects.""" def __init__( self, - config_source: Union[str, SimulationConfig], + config_source: ConfigSource, output_dir: str, output_prefix: str = "output", photon_multiplier: int = 1, quantization_scale: float = 1.0, - scoring_routine: Union[ScoringRoutine, int] = ScoringRoutine.SCATTWIN, + scoring_routine: ScoringRoutine | int = ScoringRoutine.SCATTWIN, ) -> None: - super().__init__( + if sirf is None: + raise ImportError("SirfSimindAdaptor requires the SIRF Python package.") + + self.python_connector = SimindPythonConnector( config_source=config_source, output_dir=output_dir, - backend="sirf", output_prefix=output_prefix, - photon_multiplier=photon_multiplier, quantization_scale=quantization_scale, - scoring_routine=scoring_routine, ) + self._scoring_routine = ( + ScoringRoutine(scoring_routine) + if isinstance(scoring_routine, int) + else scoring_routine + ) + self._source: Any = None + self._mu_map: Any = None + self._outputs: Optional[dict[str, Any]] = None + + self.add_runtime_switch("NN", photon_multiplier) + + def set_source(self, source: Any) -> None: + self._source = source + + def set_mu_map(self, mu_map: Any) -> None: + self._mu_map = mu_map + + def set_energy_windows( + self, + lower_bounds: float | list[float], + upper_bounds: float | list[float], + scatter_orders: int | list[int], + ) -> None: + self.python_connector.set_energy_windows( + lower_bounds, upper_bounds, scatter_orders + ) + + def add_config_value(self, index: int, value: Any) -> None: + self.python_connector.add_config_value(index, value) + + def add_runtime_switch(self, switch: str, value: Any) -> None: + self.python_connector.add_runtime_switch(switch, value) + + def run(self, runtime_operator: Optional[RuntimeOperator] = None) -> dict[str, Any]: + self._validate_inputs() + assert self._source is not None + assert self._mu_map is not None + + source_arr = np.asarray(get_array(self._source), dtype=np.float32) + mu_arr = np.asarray(get_array(self._mu_map), dtype=np.float32) + voxel_size_mm = self._extract_voxel_size_mm(self._source) + + self.python_connector.configure_voxel_phantom( + source=source_arr, + mu_map=mu_arr, + voxel_size_mm=voxel_size_mm, + scoring_routine=self._scoring_routine, + ) + raw_outputs = self.python_connector.run(runtime_operator=runtime_operator) + self._outputs = { + key: sirf.AcquisitionData(str(result.header_path)) + for key, result in raw_outputs.items() + } + return self._outputs + + def get_outputs(self) -> Dict[str, Any]: + if self._outputs is None: + raise RuntimeError("Run the adaptor first to produce outputs.") + return self._outputs + + def get_total_output(self, window: int = 1) -> Any: + return self._get_component("tot", window) + + def get_scatter_output(self, window: int = 1) -> Any: + return self._get_component("sca", window) + + def get_primary_output(self, window: int = 1) -> Any: + return self._get_component("pri", window) + + def get_air_output(self, window: int = 1) -> Any: + return self._get_component("air", window) + + def get_penetrate_output(self, component: PenetrateOutputType | str) -> Any: + outputs = self.get_outputs() + key = ( + component.slug if isinstance(component, PenetrateOutputType) else component + ) + if key not in outputs: + available = ", ".join(sorted(outputs)) + raise KeyError(f"Output {key!r} not available. Available: {available}") + return outputs[key] + + def list_available_outputs(self) -> list[str]: + return sorted(self.get_outputs().keys()) + + def get_scoring_routine(self) -> ScoringRoutine: + return self._scoring_routine + + def get_config(self) -> SimulationConfig: + return self.python_connector.get_config() + + def _get_component(self, prefix: str, window: int) -> Any: + outputs = self.get_outputs() + key = f"{prefix}_w{window}" + if key not in outputs: + available = ", ".join(sorted(outputs)) + raise KeyError(f"Output {key!r} not available. Available: {available}") + return outputs[key] + + @staticmethod + def _extract_voxel_size_mm(image: Any) -> float: + return extract_voxel_size_mm(image=image, backend_name="SIRF") + + def _validate_inputs(self) -> None: + if self._source is None or self._mu_map is None: + raise ValueError("Both source and mu_map must be set before run().") + + source_shape = np.asarray(get_array(self._source)).shape + mu_shape = np.asarray(get_array(self._mu_map)).shape + if source_shape != mu_shape: + raise ValueError( + f"source and mu_map must have matching shapes, got " + f"{source_shape} and {mu_shape}" + ) __all__ = ["SirfSimindAdaptor"] diff --git a/sirf_simind_connection/connectors/stir_adaptor.py b/sirf_simind_connection/connectors/stir_adaptor.py index ad78cc1..38fe985 100644 --- a/sirf_simind_connection/connectors/stir_adaptor.py +++ b/sirf_simind_connection/connectors/stir_adaptor.py @@ -1,37 +1,164 @@ -""" -STIR/SIMIND adaptor facade. -""" +"""STIR adaptor implemented on top of the connector-first NumPy pipeline.""" from __future__ import annotations -from typing import Union +from typing import Any, Dict, Optional -from sirf_simind_connection.connectors.base import NativeBackendConnector +import numpy as np + +from sirf_simind_connection.connectors._spacing import extract_voxel_size_mm +from sirf_simind_connection.connectors.base import BaseConnector +from sirf_simind_connection.connectors.python_connector import ( + ConfigSource, + RuntimeOperator, + SimindPythonConnector, +) from sirf_simind_connection.core.config import SimulationConfig -from sirf_simind_connection.core.types import ScoringRoutine +from sirf_simind_connection.core.types import PenetrateOutputType, ScoringRoutine +from sirf_simind_connection.utils import get_array + +try: + import stir +except ImportError: # pragma: no cover - optional dependency + stir = None # type: ignore[assignment] -class StirSimindAdaptor(NativeBackendConnector): - """Adaptor that consumes and returns STIR-native data objects.""" + +class StirSimindAdaptor(BaseConnector): + """Adaptor consuming/returning STIR-native objects.""" def __init__( self, - config_source: Union[str, SimulationConfig], + config_source: ConfigSource, output_dir: str, output_prefix: str = "output", photon_multiplier: int = 1, quantization_scale: float = 1.0, - scoring_routine: Union[ScoringRoutine, int] = ScoringRoutine.SCATTWIN, + scoring_routine: ScoringRoutine | int = ScoringRoutine.SCATTWIN, ) -> None: - super().__init__( + if stir is None: + raise ImportError("StirSimindAdaptor requires the STIR Python package.") + + self.python_connector = SimindPythonConnector( config_source=config_source, output_dir=output_dir, - backend="stir", output_prefix=output_prefix, - photon_multiplier=photon_multiplier, quantization_scale=quantization_scale, - scoring_routine=scoring_routine, ) + self._scoring_routine = ( + ScoringRoutine(scoring_routine) + if isinstance(scoring_routine, int) + else scoring_routine + ) + self._source: Any = None + self._mu_map: Any = None + self._outputs: Optional[dict[str, Any]] = None + + self.add_runtime_switch("NN", photon_multiplier) + + def set_source(self, source: Any) -> None: + self._source = source + + def set_mu_map(self, mu_map: Any) -> None: + self._mu_map = mu_map + + def set_energy_windows( + self, + lower_bounds: float | list[float], + upper_bounds: float | list[float], + scatter_orders: int | list[int], + ) -> None: + self.python_connector.set_energy_windows( + lower_bounds, upper_bounds, scatter_orders + ) + + def add_config_value(self, index: int, value: Any) -> None: + self.python_connector.add_config_value(index, value) + + def add_runtime_switch(self, switch: str, value: Any) -> None: + self.python_connector.add_runtime_switch(switch, value) + + def run(self, runtime_operator: Optional[RuntimeOperator] = None) -> dict[str, Any]: + self._validate_inputs() + assert self._source is not None + assert self._mu_map is not None + + source_arr = np.asarray(get_array(self._source), dtype=np.float32) + mu_arr = np.asarray(get_array(self._mu_map), dtype=np.float32) + voxel_size_mm = self._extract_voxel_size_mm(self._source) + + self.python_connector.configure_voxel_phantom( + source=source_arr, + mu_map=mu_arr, + voxel_size_mm=voxel_size_mm, + scoring_routine=self._scoring_routine, + ) + raw_outputs = self.python_connector.run(runtime_operator=runtime_operator) + self._outputs = { + key: stir.ProjData.read_from_file(str(result.header_path)) + for key, result in raw_outputs.items() + } + return self._outputs + + def get_outputs(self) -> Dict[str, Any]: + if self._outputs is None: + raise RuntimeError("Run the adaptor first to produce outputs.") + return self._outputs + + def get_total_output(self, window: int = 1) -> Any: + return self._get_component("tot", window) + + def get_scatter_output(self, window: int = 1) -> Any: + return self._get_component("sca", window) + + def get_primary_output(self, window: int = 1) -> Any: + return self._get_component("pri", window) + + def get_air_output(self, window: int = 1) -> Any: + return self._get_component("air", window) + + def get_penetrate_output(self, component: PenetrateOutputType | str) -> Any: + outputs = self.get_outputs() + key = ( + component.slug if isinstance(component, PenetrateOutputType) else component + ) + if key not in outputs: + available = ", ".join(sorted(outputs)) + raise KeyError(f"Output {key!r} not available. Available: {available}") + return outputs[key] + + def list_available_outputs(self) -> list[str]: + return sorted(self.get_outputs().keys()) + + def get_scoring_routine(self) -> ScoringRoutine: + return self._scoring_routine + + def get_config(self) -> SimulationConfig: + return self.python_connector.get_config() + + def _get_component(self, prefix: str, window: int) -> Any: + outputs = self.get_outputs() + key = f"{prefix}_w{window}" + if key not in outputs: + available = ", ".join(sorted(outputs)) + raise KeyError(f"Output {key!r} not available. Available: {available}") + return outputs[key] + + @staticmethod + def _extract_voxel_size_mm(image: Any) -> float: + return extract_voxel_size_mm(image=image, backend_name="STIR") + + def _validate_inputs(self) -> None: + if self._source is None or self._mu_map is None: + raise ValueError("Both source and mu_map must be set before run().") + + source_shape = np.asarray(get_array(self._source)).shape + mu_shape = np.asarray(get_array(self._mu_map)).shape + if source_shape != mu_shape: + raise ValueError( + f"source and mu_map must have matching shapes, got " + f"{source_shape} and {mu_shape}" + ) __all__ = ["StirSimindAdaptor"] diff --git a/sirf_simind_connection/core/__init__.py b/sirf_simind_connection/core/__init__.py index 3ab17ab..96b1954 100644 --- a/sirf_simind_connection/core/__init__.py +++ b/sirf_simind_connection/core/__init__.py @@ -1,42 +1,7 @@ -""" -Numerical core: configuration parsing and simulator. -""" +"""Core primitives for connector-first workflows.""" +from .config import SimulationConfig +from .types import ScoringRoutine -# Lazy imports to avoid SIRF dependencies in CI -def __getattr__(name): - if name == "ScoringRoutine": - from .types import ScoringRoutine - return ScoringRoutine - elif name == "SimulationConfig": - from .config import SimulationConfig - - return SimulationConfig - elif name == "SimindSimulator": - from .simulator import SimindSimulator - - return SimindSimulator - elif name == "create_penetrate_simulator": - from .simulator import create_penetrate_simulator - - return create_penetrate_simulator - elif name == "create_scattwin_simulator": - from .simulator import create_scattwin_simulator - - return create_scattwin_simulator - elif name == "create_simulator_from_template": - from .simulator import create_simulator_from_template - - return create_simulator_from_template - raise AttributeError(f"module '{__name__}' has no attribute '{name}'") - - -__all__ = [ - "SimulationConfig", - "SimindSimulator", - "create_simulator_from_template", - "create_penetrate_simulator", - "create_scattwin_simulator", - "ScoringRoutine", -] +__all__ = ["ScoringRoutine", "SimulationConfig"] diff --git a/sirf_simind_connection/core/backend_adapter.py b/sirf_simind_connection/core/backend_adapter.py deleted file mode 100644 index f966b2c..0000000 --- a/sirf_simind_connection/core/backend_adapter.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -Backend input adapter for consistent wrapping and backend enforcement. - -This module provides the BackendInputAdapter class that consolidates all -backend detection, wrapping, and consistency enforcement logic that was -previously duplicated across set_source, set_mu_map, and set_template_sinogram. -""" - -from typing import Any, Callable, Optional, Type, Union - -from sirf_simind_connection.utils.backend_access import BACKEND_AVAILABLE, BACKENDS -from sirf_simind_connection.utils.sirf_stir_utils import register_and_enforce_backend - - -# Unpack needed interfaces -ensure_image_interface = BACKENDS.wrappers.ensure_image_interface -ensure_acquisition_interface = BACKENDS.wrappers.ensure_acquisition_interface -detect_image_backend = BACKENDS.detection.detect_image_backend -detect_acquisition_backend = BACKENDS.detection.detect_acquisition_backend -detect_backend_from_interface = BACKENDS.detection.detect_backend_from_interface -ImageDataInterface = BACKENDS.types.ImageDataInterface -AcquisitionDataInterface = BACKENDS.types.AcquisitionDataInterface - - -class BackendInputAdapter: - """Handle backend detection and wrapping for simulator inputs. - - This adapter eliminates duplicate backend handling logic across set_source, - set_mu_map, and set_template_sinogram by providing a single place to: - 1. Detect the backend from input objects - 2. Wrap inputs with the appropriate interface - 3. Enforce backend consistency (no mixing SIRF and STIR) - - Example: - >>> adapter = BackendInputAdapter() - >>> source = adapter.wrap_image('source.hv') # First input sets backend - >>> mu_map = adapter.wrap_image('mu_map.hv') # Must match backend - >>> template = adapter.wrap_acquisition('template.hs') # Must match backend - """ - - def __init__(self): - """Initialize the adapter with no backend preference.""" - self.preferred_backend: Optional[str] = None - - def wrap_image( - self, image: Union[str, ImageDataInterface, Any] - ) -> ImageDataInterface: - """Detect backend, enforce consistency, and wrap image input. - - Args: - image: Image input (path, interface, or native object) - - Returns: - Wrapped ImageDataInterface - - Raises: - ImportError: If backends are not available - ValueError: If image backend conflicts with preferred_backend - """ - return self._wrap_value( - value=image, - detector=detect_image_backend, - ensure_fn=ensure_image_interface, - interface_type=ImageDataInterface, - missing_backend_message=( - "SIRF/STIR backend wrappers are not available to load image data" - ), - ) - - def wrap_acquisition( - self, acquisition: Union[str, AcquisitionDataInterface, Any] - ) -> AcquisitionDataInterface: - """Detect backend, enforce consistency, and wrap acquisition input. - - Args: - acquisition: Acquisition input (path, interface, or native object) - - Returns: - Wrapped AcquisitionDataInterface - - Raises: - ImportError: If backends are not available - ValueError: If acquisition backend conflicts with preferred_backend - """ - return self._wrap_value( - value=acquisition, - detector=detect_acquisition_backend, - ensure_fn=ensure_acquisition_interface, - interface_type=AcquisitionDataInterface, - missing_backend_message=( - "SIRF/STIR backend wrappers are not available to load acquisition data" - ), - ) - - def get_preferred_backend(self) -> Optional[str]: - """Get the currently preferred backend ('sirf', 'stir', or None). - - Returns: - Preferred backend name or None if not yet determined - """ - return self.preferred_backend - - def enforce_backend(self, backend_hint: Optional[str]) -> Optional[str]: - """Force a backend preference (e.g., when callers request native outputs).""" - if backend_hint is None: - return self.preferred_backend - - normalized = backend_hint.lower() - self.preferred_backend = register_and_enforce_backend( - normalized, self.preferred_backend - ) - return self.preferred_backend - - def _wrap_value( - self, - value: Union[str, Any], - detector: Optional[Callable[[Any], Optional[str]]], - ensure_fn: Optional[Callable[[Any, Optional[str]], Any]], - interface_type: Type[Any], - missing_backend_message: str, - ) -> Any: - """Shared helper for wrapping images/acquisitions with backend safety.""" - if not BACKEND_AVAILABLE or ensure_fn is None: - raise ImportError(missing_backend_message) - - # Detect backend from non-string inputs before wrapping - if not isinstance(value, str): - backend = detector(value) if detector else None - if backend is None and isinstance(value, interface_type): - backend = detect_backend_from_interface(value) - self.preferred_backend = register_and_enforce_backend( - backend, self.preferred_backend - ) - - wrapped = ensure_fn(value, preferred_backend=self.preferred_backend) - - backend = ( - detect_backend_from_interface(wrapped) - if detect_backend_from_interface - else None - ) - self.preferred_backend = register_and_enforce_backend( - backend, self.preferred_backend - ) - return wrapped - - -__all__ = ["BackendInputAdapter"] diff --git a/sirf_simind_connection/core/components.py b/sirf_simind_connection/core/components.py deleted file mode 100644 index b7e41a7..0000000 --- a/sirf_simind_connection/core/components.py +++ /dev/null @@ -1,357 +0,0 @@ -""" -Refactored SimindSimulator components with better separation of concerns and -penetrate support. -Each component has a single responsibility, making the code easier to maintain and test. -""" - -import logging -import subprocess -from dataclasses import dataclass -from pathlib import Path -from typing import Dict, List, Optional, Tuple - -# Conditional import for SIRF to avoid CI dependencies -from sirf_simind_connection.utils.import_helpers import get_sirf_types - -# Import types that don't depend on SIRF -from .types import ( - SIMIND_VOXEL_UNIT_CONVERSION, - RotationDirection, - SimulationError, - ValidationError, -) - - -ImageData, AcquisitionData, SIRF_AVAILABLE = get_sirf_types() - -# ============================================================================= -# DATA CLASSES -# ============================================================================= - - -@dataclass -class ImageGeometry: - """Represents 3D image geometry.""" - - dim_x: int - dim_y: int - dim_z: int - voxel_x: float # mm - voxel_y: float # mm - voxel_z: float # mm - - @classmethod - def from_image(cls, image) -> "ImageGeometry": - """Extract geometry from a backend-agnostic image object. - - Args: - image: Image object with dimensions() and voxel_sizes() methods - (supports both SIRF and STIR backends) - - Returns: - ImageGeometry: Extracted geometry information - """ - # Use duck typing - works with both SIRF and STIR backend wrappers - if not (hasattr(image, "dimensions") and hasattr(image, "voxel_sizes")): - raise TypeError( - f"Image object must have dimensions() and voxel_sizes() methods. " - f"Got {type(image)}" - ) - - dims = image.dimensions() - voxels = image.voxel_sizes() - return cls( - dim_x=dims[2], - dim_y=dims[1], - dim_z=dims[0], - voxel_x=voxels[2], - voxel_y=voxels[1], - voxel_z=voxels[0], - ) - - def validate_square_pixels(self) -> None: - if abs(self.voxel_x - self.voxel_y) > 1e-6: - raise ValidationError("Image must have square pixels") - if self.dim_x != self.dim_y: - raise ValidationError("Image must have same x,y dimensions") - - -@dataclass -class EnergyWindow: - """Represents an energy window configuration.""" - - lower_bound: float - upper_bound: float - scatter_order: int - window_id: int = 1 - - -@dataclass -class RotationParameters: - """Represents rotation parameters for acquisition.""" - - direction: RotationDirection - rotation_angle: float # degrees - start_angle: float # degrees - num_projections: int - - def round_rotation_angle(self) -> None: - """Round rotation angle to nearest 180 or 360 degrees.""" - if self.rotation_angle % 360 < 1e-2: - self.rotation_angle = 360 - elif self.rotation_angle % 180 < 1e-2: - self.rotation_angle = 180 - else: - raise ValidationError( - "Rotation angle must be a multiple of 180 or 360 degrees" - ) - - def to_simind_params(self) -> Tuple[int, float]: - """Convert to SIMIND rotation switch and start angle.""" - # Map direction and angle to SIMIND rotation switches - switch_map = { - (RotationDirection.CCW, 360): 0, - (RotationDirection.CCW, 180): 1, - (RotationDirection.CW, 360): 2, - (RotationDirection.CW, 180): 3, - } - - self.round_rotation_angle() - - key = (self.direction, self.rotation_angle) - if key not in switch_map: - raise ValidationError( - f"Unsupported rotation: {self.direction.value} {self.rotation_angle}°" - ) - - # Convert start angle (STIR to SIMIND coordinate system) - simind_start = (self.start_angle + 180) % 360 - - return switch_map[key], simind_start - - -# ============================================================================= -# VALIDATORS -# ============================================================================= - - -class ImageValidator: - """Validates image inputs for SIMIND simulation.""" - - @staticmethod - def validate_compatibility(image1, image2) -> None: - """Check that two images have compatible geometry. - - Args: - image1: First image object (backend-agnostic) - image2: Second image object (backend-agnostic) - """ - geom1 = ImageGeometry.from_image(image1) - geom2 = ImageGeometry.from_image(image2) - - if (geom1.voxel_x, geom1.voxel_y, geom1.voxel_z) != ( - geom2.voxel_x, - geom2.voxel_y, - geom2.voxel_z, - ): - raise ValidationError("Images must have same voxel sizes") - - if (geom1.dim_x, geom1.dim_y, geom1.dim_z) != ( - geom2.dim_x, - geom2.dim_y, - geom2.dim_z, - ): - raise ValidationError("Images must have same dimensions") - - @staticmethod - def validate_square_pixels(image) -> None: - """Check that image has square pixels. - - Args: - image: Image object (backend-agnostic) - """ - geom = ImageGeometry.from_image(image) - geom.validate_square_pixels() - - -# ============================================================================= -# CONFIGURATION MANAGERS -# ============================================================================= - - -class GeometryManager: - """Manages geometric configuration for SIMIND.""" - - def __init__(self, config_writer): - self.config = config_writer - self.logger = logging.getLogger(__name__) - - @staticmethod - def _geometry_scalars( - geometry: ImageGeometry, - ) -> tuple[float, float, float, float, float]: - vox_xy_cm = geometry.voxel_x / SIMIND_VOXEL_UNIT_CONVERSION - vox_z_cm = geometry.voxel_z / SIMIND_VOXEL_UNIT_CONVERSION - half_z = geometry.dim_z * vox_z_cm / 2 - half_x = geometry.dim_x * vox_xy_cm / 2 - half_y = geometry.dim_y * vox_xy_cm / 2 - return vox_xy_cm, vox_z_cm, half_z, half_x, half_y - - def configure_source_geometry(self, geometry: ImageGeometry) -> None: - """Configure source image geometry parameters.""" - vox_xy_cm, _, half_z, half_x, half_y = self._geometry_scalars(geometry) - - self.config.set_value(2, half_z) - self.config.set_value(3, half_x) - self.config.set_value(4, half_y) - self.config.set_value(28, vox_xy_cm) - self.config.set_value(76, geometry.dim_x) - self.config.set_value(77, geometry.dim_y) - - self.logger.info( - f"Source geometry: {geometry.dim_x}×{geometry.dim_y}×{geometry.dim_z}" - ) - - def configure_attenuation_geometry(self, geometry: ImageGeometry) -> None: - """Configure attenuation map geometry parameters.""" - vox_xy_cm, vox_z_cm, half_z, half_x, half_y = self._geometry_scalars(geometry) - - self.config.set_value(5, half_z) - self.config.set_value(6, half_x) - self.config.set_value(7, half_y) - self.config.set_value(31, vox_xy_cm) - self.config.set_value(33, 1) - self.config.set_value(34, geometry.dim_z) - self.config.set_value(78, geometry.dim_x) - self.config.set_value(79, geometry.dim_y) - - -class AcquisitionManager: - """Manages acquisition parameters for SIMIND.""" - - def __init__(self, config_writer, runtime_switches): - self.config = config_writer - self.runtime_switches = runtime_switches - self.logger = logging.getLogger(__name__) - - def configure_rotation( - self, rotation: RotationParameters, detector_distance: float - ) -> None: - """Configure rotation parameters.""" - rotation_switch, start_angle = rotation.to_simind_params() - - self.config.set_value(29, rotation.num_projections) - self.config.set_value( - 12, detector_distance / SIMIND_VOXEL_UNIT_CONVERSION - ) # convert to cm - self.config.set_value(30, rotation_switch) - self.config.set_value(41, start_angle) - - self.logger.info( - f"Rotation: {rotation.direction.value} {rotation.rotation_angle}° " - f"from {rotation.start_angle}°" - ) - - def configure_energy_windows( - self, windows: List[EnergyWindow], output_prefix: str - ) -> None: - """Configure energy windows.""" - from sirf_simind_connection.utils.simind_utils import create_window_file - - lower_bounds = [w.lower_bound for w in windows] - upper_bounds = [w.upper_bound for w in windows] - scatter_orders = [w.scatter_order for w in windows] - - create_window_file(lower_bounds, upper_bounds, scatter_orders, output_prefix) - self.logger.info(f"Configured {len(windows)} energy windows") - - -# ============================================================================= -# FILE MANAGERS -# ============================================================================= - - -# File managers have been moved to file_managers.py -# Import them here for backward compatibility - - -# ============================================================================= -# EXECUTION ENGINE -# ============================================================================= - - -class SimindExecutor: - """Handles SIMIND subprocess execution.""" - - def __init__(self): - self.logger = logging.getLogger(__name__) - - def run_simulation( - self, - output_prefix: str, - orbit_file: Optional[Path] = None, - runtime_switches: Optional[Dict] = None, - ) -> None: - """Execute SIMIND simulation.""" - # Check for MPI parallel run - mp_value = None - if runtime_switches: - for k, v in runtime_switches.items(): - if k.lower() == "mp": - mp_value = v - break - - if mp_value is not None: - # MPI parallel run - # MP value is the number of cores to use - command = [ - "mpirun", - "-np", - str(mp_value), - "simind", - output_prefix, - output_prefix, - ] - else: - # Standard serial run - command = ["simind", output_prefix, output_prefix] - - # Add orbit file BEFORE -p flag (must be 3rd/4th/5th argument) - # Use only filename (not full path) since we chdir to output_dir before running - if orbit_file: - command.append(orbit_file.name) - - # Add -p flag for MPI AFTER orbit file - if mp_value is not None: - command.append("-p") - - if runtime_switches: - switch_parts = [] - for k, v in runtime_switches.items(): - if k.upper() == "MP": - # MP is just a flag when using mpirun - switch_parts.append(f"/{k}") - else: - switch_parts.append(f"/{k}:{v}") - switches = "".join(switch_parts) - if switches: - command.append(switches) - - self.logger.info(f"Running SIMIND: {' '.join(command)}") - - try: - subprocess.run(command, check=True) - except subprocess.CalledProcessError as e: - self.logger.error(f"SIMIND failed: {e}") - if e.stderr: - self.logger.error(f"SIMIND stderr: {e.stderr}") - raise SimulationError(f"SIMIND execution failed: {e}") - - -# ============================================================================= -# ENHANCED OUTPUT PROCESSOR -# ============================================================================= - - -# OutputProcessor has been moved to output_processor.py -# Import it here for backward compatibility diff --git a/sirf_simind_connection/core/executor.py b/sirf_simind_connection/core/executor.py new file mode 100644 index 0000000..6251807 --- /dev/null +++ b/sirf_simind_connection/core/executor.py @@ -0,0 +1,96 @@ +"""Minimal SIMIND process runner used by connector-first APIs.""" + +from __future__ import annotations + +import logging +import subprocess +from pathlib import Path +from typing import Dict, Optional + +from .types import SimulationError + + +class SimindExecutor: + """Run SIMIND as a subprocess with optional MPI switch support.""" + + def __init__(self) -> None: + self.logger = logging.getLogger(__name__) + + def run_simulation( + self, + output_prefix: str, + orbit_file: Optional[Path] = None, + runtime_switches: Optional[Dict] = None, + ) -> None: + mp_value = None + if runtime_switches: + for key, value in runtime_switches.items(): + if str(key).lower() == "mp": + mp_value = value + break + + validated_output_prefix = self._validate_cli_token(output_prefix) + validated_orbit_name = ( + self._validate_cli_token(orbit_file.name) if orbit_file else None + ) + validated_mp_value = ( + self._validate_cli_token(mp_value) if mp_value is not None else None + ) + + validated_switch_blob = None + if runtime_switches: + switch_parts = [] + for key, value in runtime_switches.items(): + if str(key).upper() == "MP": + switch_parts.append(f"/{key}") + else: + switch_parts.append(f"/{key}:{value}") + if switch_parts: + validated_switch_blob = self._validate_cli_token("".join(switch_parts)) + + if validated_mp_value is not None: + command = [ + "mpirun", + "-np", + validated_mp_value, + "simind", + validated_output_prefix, + validated_output_prefix, + ] + if validated_orbit_name is not None: + command.append(validated_orbit_name) + command.append("-p") + if validated_switch_blob is not None: + command.append(validated_switch_blob) + else: + command = ["simind", validated_output_prefix, validated_output_prefix] + if validated_orbit_name is not None: + command.append(validated_orbit_name) + if validated_switch_blob is not None: + command.append(validated_switch_blob) + + self.logger.info("Running SIMIND: %s", " ".join(command)) + try: + subprocess.run(command, check=True, shell=False) + except OSError as exc: + raise SimulationError(f"Unable to execute SIMIND command: {exc}") from exc + except subprocess.CalledProcessError as exc: + raise SimulationError(f"SIMIND execution failed: {exc}") from exc + + @staticmethod + def _validate_cli_token(value: object) -> str: + """Validate command tokens before subprocess invocation. + + This executor always runs with ``shell=False``, and each token is + validated to reject empty values, NUL bytes, and whitespace. + """ + token = str(value) + if not token: + raise SimulationError("Encountered empty command token for SIMIND call.") + if "\x00" in token: + raise SimulationError("SIMIND command token contains NUL byte.") + if any(char.isspace() for char in token): + raise SimulationError( + f"SIMIND command token contains whitespace: {token!r}" + ) + return token diff --git a/sirf_simind_connection/core/file_managers.py b/sirf_simind_connection/core/file_managers.py deleted file mode 100644 index d45b0f1..0000000 --- a/sirf_simind_connection/core/file_managers.py +++ /dev/null @@ -1,168 +0,0 @@ -""" -File management utilities for SIMIND simulation. - -This module handles preparation of input files (source, attenuation) and -management of orbit files for non-circular acquisitions. -""" - -import logging -from pathlib import Path -from typing import List, Optional - -import numpy as np - -from sirf_simind_connection.utils import get_array - -from .types import MAX_SOURCE, ORBIT_FILE_EXTENSION, SIMIND_VOXEL_UNIT_CONVERSION - - -class DataFileManager: - """Manages input data file preparation.""" - - def __init__(self, output_dir: Path, quantization_scale: float = 1.0): - self.output_dir = output_dir - self.logger = logging.getLogger(__name__) - self.temp_files: List[Path] = [] - self.quantization_scale = float(quantization_scale) - if self.quantization_scale <= 0: - raise ValueError("quantization_scale must be > 0") - - def prepare_source_file(self, source, output_prefix: str) -> str: - """Prepare source data file for SIMIND. - - Args: - source: Source image object (backend-agnostic) - output_prefix: Prefix for output filename - - Returns: - str: Output file prefix with suffix - """ - source_arr = get_array(source) - - # Normalize to uint16 range - source_max = float(np.max(source_arr)) - if source_max > 0: - source_arr = ( - source_arr / source_max * (MAX_SOURCE * self.quantization_scale) - ) - - source_arr = np.clip(np.round(source_arr), 0, MAX_SOURCE).astype(np.uint16) - - filename = f"{output_prefix}_src.smi" - filepath = self.output_dir / filename - source_arr.tofile(filepath) - - self.temp_files.append(filepath) - return output_prefix + "_src" - - def prepare_attenuation_file( - self, - mu_map, - output_prefix: str, - use_attenuation: bool, - photon_energy: float, - input_dir: Path, - ) -> str: - """Prepare attenuation data file for SIMIND. - - Args: - mu_map: Attenuation map image object (backend-agnostic) - output_prefix: Prefix for output filename - use_attenuation: Whether to use attenuation correction - photon_energy: Photon energy in keV - input_dir: Directory for input files - - Returns: - str: Output file prefix with suffix - """ - if use_attenuation: - from sirf_simind_connection.converters.attenuation import ( - attenuation_to_density, - ) - - mu_map_arr = get_array(mu_map) - mu_map_arr = ( - attenuation_to_density(mu_map_arr, photon_energy, input_dir) * 1000 - ) - else: - mu_map_arr = np.zeros(get_array(mu_map).shape) - - mu_map_arr = mu_map_arr.astype(np.uint16) - - filename = f"{output_prefix}_dns.dmi" - filepath = self.output_dir / filename - mu_map_arr.tofile(filepath) - - self.temp_files.append(filepath) - return output_prefix + "_dns" - - def cleanup_temp_files(self) -> None: - """Remove temporary files.""" - for filepath in self.temp_files: - if filepath.exists(): - filepath.unlink() - self.logger.debug(f"Removed temp file: {filepath}") - self.temp_files.clear() - - -class OrbitFileManager: - """Manages orbit files for non-circular orbits.""" - - def __init__(self, output_dir: Path): - self.output_dir = output_dir - self.logger = logging.getLogger(__name__) - - def write_orbit_file( - self, - radii: List[float], - output_prefix: str, - center_of_rotation: Optional[float] = None, - ) -> Path: - """ - Write orbit file for non-circular orbits. - - Args: - radii: List of radii in mm (STIR units) - output_prefix: Prefix for output filename - center_of_rotation: Center of rotation in pixels - - Returns: - Path to the created orbit file - - Note: - - Input radii are in mm (STIR), output file uses cm (SIMIND) - - Uses suffix "_input.cor" to avoid conflict with SIMIND's output .cor file - """ - if center_of_rotation is None: - center_of_rotation = 64 # Default center - - # Use "_input.cor" suffix to avoid SIMIND overwriting it with output .cor - orbit_file = self.output_dir / f"{output_prefix}_input{ORBIT_FILE_EXTENSION}" - - with open(orbit_file, "w") as f: - for radius_mm in radii: - # Convert from mm (STIR) to cm (SIMIND) - radius_cm = radius_mm / SIMIND_VOXEL_UNIT_CONVERSION - f.write(f"{radius_cm:.6f}\t{center_of_rotation}\t\n") - - self.logger.info( - f"Orbit file written: {orbit_file} " - f"({len(radii)} radii, mm->cm conversion applied)" - ) - return orbit_file - - def read_orbit_file(self, orbit_file: Path) -> List[float]: - """Read orbit file and return radii in mm.""" - radii = [] - with open(orbit_file, "r") as f: - for line in f: - parts = line.strip().split() - if parts: - radius_cm = float(parts[0]) - radii.append( - radius_cm * SIMIND_VOXEL_UNIT_CONVERSION - ) # Convert to mm - return radii - - -__all__ = ["DataFileManager", "OrbitFileManager"] diff --git a/sirf_simind_connection/core/output_processor.py b/sirf_simind_connection/core/output_processor.py deleted file mode 100644 index 5356920..0000000 --- a/sirf_simind_connection/core/output_processor.py +++ /dev/null @@ -1,352 +0,0 @@ -""" -Output processing for SIMIND simulation results. - -This module handles post-processing of SIMIND outputs for both scattwin -and penetrate scoring routines, including conversion to STIR format and -validation of geometry parameters. -""" - -import logging -from pathlib import Path -from typing import Dict, List, Optional - -from sirf_simind_connection.utils.backend_access import BACKEND_AVAILABLE, BACKENDS -from sirf_simind_connection.utils.import_helpers import get_sirf_types -from sirf_simind_connection.utils.stir_utils import extract_attributes_from_stir - -from .types import OutputError, PenetrateOutputType, ScoringRoutine - - -_, AcquisitionData, SIRF_AVAILABLE = get_sirf_types() - -# Unpack interfaces needed by output processor -create_acquisition_data = BACKENDS.factories.create_acquisition_data -ensure_acquisition_interface = BACKENDS.wrappers.ensure_acquisition_interface -AcquisitionDataInterface = BACKENDS.types.AcquisitionDataInterface - - -class OutputProcessor: - """Enhanced output processor that handles both scattwin and penetrate outputs.""" - - def __init__(self, converter, output_dir: Path): - """ - Initialize the output processor. - - Args: - converter: SIMIND to STIR converter instance - output_dir: Directory containing simulation outputs - """ - self.converter = converter - self.output_dir = Path(output_dir) - self.logger = logging.getLogger(__name__) - - def process_outputs( - self, - output_prefix: str, - template_sinogram_path: Optional[str] = None, - source=None, - scoring_routine: ScoringRoutine = ScoringRoutine.SCATTWIN, - preferred_backend: Optional[str] = None, - ) -> Dict: - """ - Process outputs based on the scoring routine used. - - Args: - output_prefix: Prefix used for output files - template_sinogram_path: Path to template sinogram header file (.hs) - source: Source image for geometry reference (backend-agnostic) - scoring_routine: Scoring routine that was used - preferred_backend: Backend hint when wrapping acquired data - - Returns: - Dictionary of output name -> AcquisitionDataInterface - """ - - if scoring_routine == ScoringRoutine.SCATTWIN: - return self._process_scattwin_outputs( - output_prefix, template_sinogram_path, source, preferred_backend - ) - elif scoring_routine == ScoringRoutine.PENETRATE: - return self._process_penetrate_outputs( - output_prefix, template_sinogram_path, source, preferred_backend - ) - else: - raise ValueError( - f"Unsupported scoring routine for output processing: {scoring_routine}" - ) - - def _process_scattwin_outputs( - self, - output_prefix: str, - template_sinogram_path: Optional[str], - source, - preferred_backend: Optional[str], - ) -> Dict: - """Process scattwin routine outputs (existing functionality). - - Args: - output_prefix: Prefix used for output files - template_sinogram_path: Path to template sinogram header file - source: Source image object (backend-agnostic) - - Returns: - Dictionary of output name -> AcquisitionDataInterface - """ - - h00_files = self._find_scattwin_output_files(output_prefix) - - if not h00_files: - raise OutputError("No SIMIND scattwin output files found") - - # Process each file - for h00_file in h00_files: - self._process_single_scattwin_file(h00_file, template_sinogram_path, source) - - # Load and organize converted files - return self._load_converted_scattwin_files(output_prefix, preferred_backend) - - def _process_penetrate_outputs( - self, - output_prefix, - template_sinogram, - source, - preferred_backend: Optional[str], - ): - # Find the single .h00 file from penetrate routine - h00_file = self.converter.find_penetrate_h00_file( - output_prefix, str(self.output_dir) - ) - - if not h00_file: - raise OutputError("No penetrate .h00 file found") - - # Create multiple .hs files, one for each .bXX file - outputs = self.converter.create_penetrate_headers_from_template( - h00_file, output_prefix, str(self.output_dir) - ) - - if not outputs: - raise OutputError("No penetrate output files found") - - if preferred_backend and ensure_acquisition_interface is not None: - return { - name: ensure_acquisition_interface( - data, preferred_backend=preferred_backend - ) - for name, data in outputs.items() - } - return outputs - - def _find_scattwin_output_files(self, output_prefix: str) -> List[Path]: - """Find SIMIND scattwin output files.""" - scatter_types = ["_air_w", "_sca_w", "_tot_w", "_pri_w"] - return [ - f - for f in self.output_dir.glob("*.h00") - if any(s in f.name for s in scatter_types) and output_prefix in f.name - ] - - def _process_single_scattwin_file( - self, - h00_file: Path, - template_sinogram_path: Optional[str], - source, - ) -> None: - """Process a single scattwin output file with corrections. - - Args: - h00_file: Path to SIMIND output file - template_sinogram_path: Path to template sinogram header - source: Source image object (backend-agnostic) - """ - try: - # Apply template-based corrections - if template_sinogram_path: - self._apply_template_corrections(h00_file, template_sinogram_path) - - if source: - self._validate_scaling_factors(h00_file, source) - - # Convert to STIR format - self.converter.convert_file(str(h00_file)) - self.logger.info(f"Processed {h00_file.name}") - - except Exception as e: - self.logger.error(f"Failed to process {h00_file}: {e}") - raise OutputError(f"Failed to process {h00_file}: {e}") - - def _apply_template_corrections( - self, h00_file: Path, template_sinogram_path: str - ) -> None: - """Apply corrections based on template sinogram header file. - - Args: - h00_file: SIMIND output file to correct - template_sinogram_path: Path to template sinogram header file (.hs) - """ - try: - # Extract attributes from template sinogram (backend-agnostic!) - attributes = extract_attributes_from_stir(template_sinogram_path) - - # Template correction 1: Set acquisition time (projections × time per - # projection) - if "number_of_projections" in attributes and "image_duration" in attributes: - time_per_projection = ( - attributes["image_duration"] / attributes["number_of_projections"] - ) - total_duration = ( - attributes["number_of_projections"] * time_per_projection - ) - self.converter.edit_parameter( - str(h00_file), "!image duration (sec)[1]", total_duration - ) - self.logger.debug(f"Set image duration: {total_duration} s") - - # Template correction 2: Check and correct radius from template sinogram - if "height_to_detector_surface" in attributes: - expected_radius = attributes[ - "height_to_detector_surface" - ] # Already in mm from STIR - current_radius = self.converter.read_parameter( - str(h00_file), ";# Radius" - ) - - if ( - current_radius is None - or abs(float(current_radius) - expected_radius) > 0.1 - ): - self.logger.info( - f"Correcting radius from template: {expected_radius:.4f} mm" - ) - self.converter.edit_parameter( - str(h00_file), ";#Radius", expected_radius - ) - - # Template correction 3: Handle non-circular orbits if present - if attributes.get("orbit") == "non-circular" and "radii" in attributes: - radii = attributes["radii"] # Already in mm from STIR - orbits_string = "{" + ",".join([f"{r:.1f}" for r in radii]) + "}" - self.converter.add_parameter( - str(h00_file), - "Radii", - orbits_string, - 59, # line number to insert at - ) - self.logger.debug("Added non-circular orbit radii from template") - - except Exception as e: - self.logger.warning( - f"Failed to apply template corrections to {h00_file}: {e}" - ) - - def _validate_scaling_factors(self, h00_file: Path, source) -> None: - """Validate and fix scaling factors against source image. - - Args: - h00_file: Path to SIMIND output file - source: Source image object (backend-agnostic) - """ - try: - # Get voxel size from source image (in mm) - voxel_size = source.voxel_sizes()[2] # Get voxel size in z-direction - self.logger.debug(f"Source voxel size: {voxel_size:.3f} mm") - - # Validate and fix scaling factors using the converter - scaling_ok = self.converter.validate_and_fix_scaling_factors( - str(h00_file), source, tolerance=0.00001 - ) - - if not scaling_ok: - self.logger.info(f"Corrected scaling factors in {h00_file.name}") - else: - self.logger.debug(f"Scaling factors validated for {h00_file.name}") - - except Exception as e: - self.logger.warning( - f"Failed to validate scaling factors for {h00_file}: {e}" - ) - - def _load_converted_scattwin_files( - self, output_prefix: str, preferred_backend: Optional[str] - ) -> Dict: - """Load all converted scattwin .hs files. - - Args: - output_prefix: Prefix used for output files - - Returns: - Dictionary of output name -> AcquisitionDataInterface - """ - output = {} - hs_files = list(self.output_dir.glob(f"*{output_prefix}*.hs")) - - for hs_file in hs_files: - try: - # Extract scatter type and window from filename - key = self._extract_output_key(hs_file.name) - if BACKEND_AVAILABLE: - logging.info(f"Loading {hs_file} using backend factory") - if ensure_acquisition_interface is not None: - output[key] = ensure_acquisition_interface( - str(hs_file), preferred_backend=preferred_backend - ) - else: - output[key] = create_acquisition_data(str(hs_file)) - else: - logging.info(f"Loading {hs_file} using SIRF") - output[key] = AcquisitionData(str(hs_file)) - except Exception as e: - self.logger.error(f"Failed to load {hs_file}: {e}") - continue - - if not output: - raise OutputError( - f"No valid scattwin output files found with prefix {output_prefix} " - f"found in {self.output_dir}" - ) - - self.logger.info(f"Loaded {len(output)} scattwin output files") - return output - - def _extract_output_key(self, filename: str) -> str: - """Extract scatter type and window from filename.""" - # Parse filename to extract scatter type and window number - parts = filename.split("_") - if len(parts) >= 2: - scatter_type = parts[-2] - window = parts[-1].split(".")[0] - return f"{scatter_type}_{window}" - return filename - - def get_penetrate_component_description( - self, component: PenetrateOutputType - ) -> str: - """Get detailed description for penetrate output component.""" - return component.description - - def list_expected_files( - self, output_prefix: str, scoring_routine: ScoringRoutine - ) -> List[str]: - """List expected output files for a given scoring routine.""" - if scoring_routine == ScoringRoutine.SCATTWIN: - # Scattwin files for window 1 (most common case) - return [ - f"{output_prefix}_tot_w1.a00", - f"{output_prefix}_sca_w1.a00", - f"{output_prefix}_pri_w1.a00", - f"{output_prefix}_air_w1.a00", - ] - elif scoring_routine == ScoringRoutine.PENETRATE: - # All possible penetrate files - return [f"{output_prefix}.b{i:02d}" for i in range(1, 20)] - else: - return [] - - def cleanup_temp_files(self) -> None: - """Clean up any temporary files created during processing.""" - # This could be extended to clean up converter temporary files - # For now, just log that cleanup was called - self.logger.debug("Output processor cleanup completed") - - -__all__ = ["OutputProcessor"] diff --git a/sirf_simind_connection/core/simulator.py b/sirf_simind_connection/core/simulator.py deleted file mode 100644 index a9b7d9d..0000000 --- a/sirf_simind_connection/core/simulator.py +++ /dev/null @@ -1,925 +0,0 @@ -""" -Simind Simulator Class. -""" - -import logging -import os -from pathlib import Path -from typing import Any, Dict, List, Optional, Union - -import yaml - -from sirf_simind_connection.converters.simind_to_stir import SimindToStirConverter - -# Import backend factory and interfaces using centralized access -from sirf_simind_connection.utils.backend_access import BACKEND_AVAILABLE, BACKENDS -from sirf_simind_connection.utils.stir_utils import extract_attributes_from_stir - -from .backend_adapter import BackendInputAdapter -from .components import ( - SIMIND_VOXEL_UNIT_CONVERSION, - AcquisitionManager, - EnergyWindow, - GeometryManager, - ImageGeometry, - ImageValidator, - RotationParameters, - SimindExecutor, -) -from .config import RuntimeSwitches, SimulationConfig -from .file_managers import DataFileManager, OrbitFileManager -from .output_processor import OutputProcessor -from .types import ( - OutputError, - PenetrateOutputType, - RotationDirection, - ScoringRoutine, - ValidationError, -) - - -to_native_acquisition = BACKENDS.wrappers.to_native_acquisition -AcquisitionDataInterface = BACKENDS.types.AcquisitionDataInterface -ImageDataInterface = BACKENDS.types.ImageDataInterface - - -class SimindSimulator: - """ - Enhanced SIMIND simulator with support for both scattwin and penetrate - scoring routines. - """ - - def __init__( - self, - config_source: Union[str, os.PathLike[str], SimulationConfig], - output_dir: str, - output_prefix: str = "output", - photon_multiplier: int = 1, - quantization_scale: float = 1.0, - scoring_routine: Union[ScoringRoutine, int] = ScoringRoutine.SCATTWIN, - ): - """ - Initialize the simulator with flexible configuration source. - - Args: - config_source: Can be string path to .smc/.yaml file or - SimulationConfig object - output_dir: Directory for simulation outputs - output_prefix: Prefix for output files - photon_multiplier: Photon multiplier for NN runtime switch - quantization_scale: Source quantization scale relative to MAX_SOURCE - scoring_routine: Scoring routine to use (SCATTWIN or PENETRATE) - """ - self.logger = logging.getLogger(__name__) - - # Setup output directory - self.output_dir = Path(output_dir) - self.output_dir.mkdir(parents=True, exist_ok=True) - self.output_prefix = output_prefix - - # Handle scoring routine - if isinstance(scoring_routine, int): - self.scoring_routine = ScoringRoutine(scoring_routine) - else: - self.scoring_routine = scoring_routine - - # Initialize configuration based on source type - self.config, self.template_path = self._initialize_config(config_source) - - # Initialize runtime switches - self.runtime_switches = RuntimeSwitches() - self.runtime_switches.set_switch("NN", photon_multiplier) - - # Initialize backend adapter for consistent input handling - self.backend_adapter = BackendInputAdapter() - - # Initialize components with enhanced output processor - self.converter = SimindToStirConverter() - self.geometry_manager = GeometryManager(self.config) - self.acquisition_manager = AcquisitionManager( - self.config, self.runtime_switches - ) - self.file_manager = DataFileManager( - self.output_dir, quantization_scale=quantization_scale - ) - self.orbit_manager = OrbitFileManager(self.output_dir) - self.executor = SimindExecutor() - self.output_processor = OutputProcessor(self.converter, self.output_dir) - - # Set up for voxelised phantom simulation - self._configure_voxelised_phantom() - - # Configure scoring routine - self._configure_scoring_routine() - - # Simulation state - self.source: Optional[ImageDataInterface] = None - self.mu_map: Optional[ImageDataInterface] = None - # Template sinogram: store both filepath (backend-agnostic) and wrapped object - self.template_sinogram: Optional[AcquisitionDataInterface] = None - self.template_sinogram_path: Optional[str] = None - self.attributes: Dict = {} - self.energy_windows: List[EnergyWindow] = [] - self.rotation_params: Optional[RotationParameters] = None - self.non_circular_orbit = False - self.orbit_radii: List[float] = [] - - # Results - self._outputs: Optional[Dict[str, AcquisitionDataInterface]] = None - - self.logger.info( - f"Simulator initialized with {self.scoring_routine.name} scoring routine" - ) - - def _configure_scoring_routine(self) -> None: - """Configure the scoring routine in the simulation config.""" - self.config.set_value( - 84, self.scoring_routine.value - ) # Index 84 = scoring routine - - # Set appropriate flags based on scoring routine - if self.scoring_routine == ScoringRoutine.PENETRATE: - # Penetrate routine may need specific configuration - self.logger.info("Configured for penetrate scoring routine") - else: - # Default scattwin configuration - self.logger.info("Configured for scattwin scoring routine") - - @property - def _preferred_backend(self) -> Optional[str]: - """Get the preferred backend from the adapter. - - This property maintains backward compatibility for code that accessed - _preferred_backend directly, while delegating to the BackendInputAdapter. - """ - return self.backend_adapter.get_preferred_backend() - - def _initialize_config( - self, config_source: Union[str, os.PathLike[str], SimulationConfig] - ) -> tuple[SimulationConfig, Optional[Path]]: - """ - Initialize configuration from various source types. - - Returns: - tuple: (SimulationConfig object, template_path if applicable) - """ - if isinstance(config_source, SimulationConfig): - # Direct SimulationConfig object - self.logger.info("Using provided SimulationConfig object") - return config_source, None - - elif isinstance(config_source, (str, os.PathLike)): - config_path = Path(config_source).resolve() - - if not config_path.exists(): - raise FileNotFoundError( - f"Configuration file not found: {config_source}" - ) - - if config_path.suffix.lower() == ".smc": - # SMC template file (original behavior) - self.logger.info(f"Loading SMC template file: {config_path}") - return SimulationConfig(str(config_path)), config_path - - elif config_path.suffix.lower() in [".yaml", ".yml"]: - # YAML configuration file - self.logger.info(f"Loading YAML configuration file: {config_path}") - return SimulationConfig(str(config_path)), config_path - - else: - raise ValueError( - f"Unsupported configuration file type: {config_path.suffix}" - ) - - else: - raise TypeError( - f"config_source must be string path or SimulationConfig object, " - f"got {type(config_source)}" - ) - - def _configure_voxelised_phantom(self) -> None: - """Configure settings for voxelised phantom simulation.""" - self.config.set_flag(5, True) # SPECT study - self.config.set_value(15, -1) # source type - self.config.set_value(14, -1) # phantom type - self.config.set_flag(14, True) # write to interfile header - - # ============================================================================= - # CONFIGURATION ACCESS METHODS - # ============================================================================= - - def get_config(self) -> SimulationConfig: - """Get the current simulation configuration.""" - return self.config - - def save_config_as_yaml(self, yaml_path: str) -> None: - """Save current configuration to YAML file.""" - yaml_data = { - "config_values": self.config.get_all_values(), - "config_flags": self.config.get_all_flags(), - "data_files": self.config.get_all_data_files(), - } - - if hasattr(self.config, "get_photon_energy"): - yaml_data["photon_energy"] = self.config.get_photon_energy() - - with open(yaml_path, "w") as f: - yaml.dump(yaml_data, f, default_flow_style=False, indent=2) - - self.logger.info(f"Configuration saved to YAML: {yaml_path}") - - # ============================================================================= - # INPUT CONFIGURATION METHODS (UNCHANGED) - # ============================================================================= - - def set_source(self, source: Union[str, ImageDataInterface]) -> None: - """Set the source image. - - The backend adapter handles detection, wrapping, and consistency enforcement. - """ - try: - # Adapter handles all backend detection, wrapping, and enforcement - self.source = self.backend_adapter.wrap_image(source) - except Exception as exc: - raise TypeError( - "source must be a string path or backend-compatible image object" - ) from exc - - # Validate and configure geometry - ImageValidator.validate_square_pixels(self.source) - geometry = ImageGeometry.from_image(self.source) - self.geometry_manager.configure_source_geometry(geometry) - - self.logger.info( - f"Source configured: {geometry.dim_x}×{geometry.dim_y}×{geometry.dim_z}" - ) - - def set_mu_map(self, mu_map: Union[str, ImageDataInterface]) -> None: - """Set the attenuation map. - - The backend adapter handles detection, wrapping, and consistency enforcement. - """ - try: - # Adapter handles all backend detection, wrapping, and enforcement - self.mu_map = self.backend_adapter.wrap_image(mu_map) - except Exception as exc: - raise TypeError( - "mu_map must be a string path or backend-compatible image object" - ) from exc - - # Validate and configure geometry - ImageValidator.validate_square_pixels(self.mu_map) - geometry = ImageGeometry.from_image(self.mu_map) - self.geometry_manager.configure_attenuation_geometry(geometry) - - self.logger.info( - f"Attenuation map configured: {geometry.dim_x}×{geometry.dim_y}" - f"×{geometry.dim_z}" - ) - - def set_energy_windows( - self, - lower_bounds: Union[float, List[float]], - upper_bounds: Union[float, List[float]], - scatter_orders: Union[int, List[int]], - ) -> None: - """Set energy windows for the simulation.""" - - if self.scoring_routine == ScoringRoutine.PENETRATE: - self.logger.warning( - "Energy windows configuration is not applicable for penetrate routine" - ) - self.logger.warning( - "Penetrate routine analyzes all interactions regardless of " - "energy windows" - ) - - # Convert single values to lists - if not isinstance(lower_bounds, list): - lower_bounds = [lower_bounds] - if not isinstance(upper_bounds, list): - upper_bounds = [upper_bounds] - if not isinstance(scatter_orders, list): - scatter_orders = [scatter_orders] - - # Create EnergyWindow objects - self.energy_windows = [ - EnergyWindow(lb, ub, so, i + 1) - for i, (lb, ub, so) in enumerate( - zip(lower_bounds, upper_bounds, scatter_orders) - ) - ] - - self.logger.info(f"Configured {len(self.energy_windows)} energy windows") - - def set_collimator_routine(self, enabled: bool) -> None: - """ - Enable or disable collimator modeling (penetration routine). - - This sets index 53 in the SIMIND configuration: - - 0: No collimator modeling (geometric only) - - 1: Full collimator modeling (penetration, scatter, etc.) - - Args: - enabled (bool): True to enable collimator modeling, False for - geometric only. - """ - self.config.set_value(53, 1 if enabled else 0) - mode_str = "enabled" if enabled else "disabled" - self.logger.info( - f"Collimator routine {mode_str} (index 53 = {1 if enabled else 0})" - ) - - def set_template_sinogram( - self, template_sinogram: Union[str, AcquisitionDataInterface] - ) -> None: - """Set template sinogram and extract acquisition parameters. - - The backend adapter handles detection, wrapping, and consistency enforcement. - - Args: - template_sinogram: Filepath to .hs header, backend interface, or native - acquisition object compatible with the wrapper factory. - """ - import tempfile - - if isinstance(template_sinogram, str): - # Store filepath directly (backend-agnostic) - self.template_sinogram_path = template_sinogram - # Adapter handles wrapping with backend consistency - self.template_sinogram = self.backend_adapter.wrap_acquisition( - template_sinogram - ) - else: - # Adapter handles all backend detection, wrapping, and enforcement - wrapped = self.backend_adapter.wrap_acquisition(template_sinogram) - - # Object provided - write to temp file to get filepath - temp_file = tempfile.NamedTemporaryFile( - mode="w", suffix=".hs", delete=False, dir=str(self.output_dir) - ) - temp_path = temp_file.name - temp_file.close() - - # Write object to file - wrapped.write(temp_path) - self.template_sinogram_path = temp_path - - # Clone the object - self.template_sinogram = wrapped.clone() - - # Extract parameters from template using filepath (backend-agnostic!) - self.attributes = extract_attributes_from_stir(self.template_sinogram_path) - - # Set up rotation parameters - direction = ( - RotationDirection.CCW - if self.attributes["direction_of_rotation"].lower() == "ccw" - else RotationDirection.CW - ) - self.rotation_params = RotationParameters( - direction=direction, - rotation_angle=self.attributes["extent_of_rotation"], - start_angle=self.attributes["start_angle"], - num_projections=self.attributes["number_of_projections"], - ) - - # Configure acquisition - detector_distance = self.attributes["height_to_detector_surface"] - self.acquisition_manager.configure_rotation( - self.rotation_params, detector_distance - ) - - # Handle non-circular orbits - if ( - self.attributes.get("orbit") == "non-circular" - and "radii" in self.attributes - ): - self.non_circular_orbit = True - self.orbit_radii = self.attributes["radii"] - self.logger.info("Non-circular orbit detected") - - self.logger.info("Template sinogram configured") - - def set_rotation_parameters( - self, - direction: str, - rotation_angle: float, - start_angle: float, - num_projections: int, - detector_distance: float, - ) -> None: - """Manually set rotation parameters.""" - direction_enum = ( - RotationDirection.CCW - if direction.lower() == "ccw" - else RotationDirection.CW - ) - - self.rotation_params = RotationParameters( - direction=direction_enum, - rotation_angle=rotation_angle, - start_angle=start_angle, - num_projections=num_projections, - ) - - self.acquisition_manager.configure_rotation( - self.rotation_params, detector_distance - ) - self.logger.info( - f"Rotation configured: {direction} {rotation_angle}° from {start_angle}°" - ) - - # ============================================================================= - # CONFIGURATION METHODS (UNCHANGED) - # ============================================================================= - - def add_config_value(self, index: int, value) -> None: - """Add a configuration index value.""" - self.config.set_value(index, value) - - def add_config_flag(self, flag: int, value: bool) -> None: - """Add a configuration flag.""" - self.config.set_flag(flag, value) - - def set_scoring_routine(self, scoring_routine: Union[ScoringRoutine, int]) -> None: - """Update scoring routine and reconfigure related settings.""" - - if isinstance(scoring_routine, int): - scoring = ScoringRoutine(scoring_routine) - else: - scoring = scoring_routine - - if scoring == self.scoring_routine: - return - - self.scoring_routine = scoring - self._configure_scoring_routine() - self.logger.info("Scoring routine updated to %s", self.scoring_routine.name) - - def add_runtime_switch(self, switch: str, value) -> None: - """ - Add a runtime switch. - - Special handling for CC (collimator): also updates text_variables[1] - in the .smc file so SIMIND can find the collimator file. - """ - self.runtime_switches.set_switch(switch, value) - - # TODO: improve handling of regularly used switches - # Sync collimator to .smc file text_variables - if switch == "CC": - self.config.text_variables[1] = str(value) - - # ============================================================================= - # SIMULATION EXECUTION - # ============================================================================= - - def run_simulation(self) -> None: - """Run the complete SIMIND simulation.""" - self.logger.info("Starting SIMIND simulation") - - # Reset previous results - self._outputs = None - - try: - # Validate inputs - self._validate_simulation_inputs() - - # Prepare simulation - self._prepare_simulation() - - # Execute SIMIND - self._execute_simulation() - - except Exception as e: - self.logger.error(f"Simulation failed: {e}") - raise - finally: - # Cleanup temporary files - self.file_manager.cleanup_temp_files() - - def _validate_simulation_inputs(self) -> None: - """Validate all inputs before simulation.""" - - # Common validation - if self.source is None or self.mu_map is None: - raise ValidationError("Both source and mu_map must be set") - - # Check image compatibility - ImageValidator.validate_compatibility(self.source, self.mu_map) - - # Routine-specific validation - if self.scoring_routine == ScoringRoutine.SCATTWIN: - if not self.energy_windows: - raise ValidationError("Energy windows must be set for scattwin routine") - elif self.scoring_routine == ScoringRoutine.PENETRATE: - # Penetrate routine doesn't need energy windows but may have other - # requirements - pass - - def _prepare_simulation(self) -> None: - """Prepare all files and configuration for simulation.""" - - # Configure energy windows only for scattwin - if self.scoring_routine == ScoringRoutine.SCATTWIN: - self.acquisition_manager.configure_energy_windows( - self.energy_windows, str(self.output_dir / self.output_prefix) - ) - - # Prepare data files (same for both routines) - source_file = self.file_manager.prepare_source_file( - self.source, self.output_prefix - ) - attenuation_file = self.file_manager.prepare_attenuation_file( - self.mu_map, - self.output_prefix, - self.config.get_flag(11), # use attenuation - self.config.get_value("photon_energy"), - self.template_path.parent if self.template_path else Path.cwd(), - ) - - # Set data files in configuration - self.config.set_data_file(6, source_file) # source file - self.config.set_data_file(5, attenuation_file) # attenuation file - - # Add PX runtime switch - required for voxelised phantoms - if self.source: - voxel_size = self.source.voxel_sizes()[-1] - self.runtime_switches.set_switch( - "PX", voxel_size / SIMIND_VOXEL_UNIT_CONVERSION - ) - self.logger.info(f"Set PX runtime switch to {voxel_size} cm") - else: - raise ValidationError("Source image must be set to determine voxel size") - - # Save configuration as .smc file for SIMIND execution - output_path = self.output_dir / self.output_prefix - self.config.save_file(output_path) - self.logger.info(f"Configuration saved as SMC file: {output_path}.smc") - - def _execute_simulation(self) -> None: - """Execute the SIMIND simulation.""" - # Change to output directory (SIMIND requirement) - original_cwd = os.getcwd() - - try: - os.chdir(self.output_dir) - - print(f"Running SIMIND simulation with output prefix: {self.output_prefix}") - print(f"in directory: {self.output_dir}") - - # Prepare orbit file if needed - orbit_file = None - self.logger.debug( - f"Orbit file check: non_circular={self.non_circular_orbit}, " - f"radii_count={len(self.orbit_radii) if self.orbit_radii else 0}" - ) - if self.non_circular_orbit and self.orbit_radii: - center_of_rotation = ( - self.source.dimensions()[1] / 2 if self.source else None - ) - orbit_file = self.orbit_manager.write_orbit_file( - self.orbit_radii, self.output_prefix, center_of_rotation - ) - else: - orbit_radii_label = ( - "empty" - if not self.orbit_radii - else f"{len(self.orbit_radii)} values" - ) - self.logger.warning( - "Skipping orbit file creation: " - f"non_circular_orbit={self.non_circular_orbit}, " - f"orbit_radii={orbit_radii_label}" - ) - - # Execute simulation - self.executor.run_simulation( - self.output_prefix, orbit_file, self.runtime_switches.switches - ) - - finally: - os.chdir(original_cwd) - - # ============================================================================= - # OUTPUT METHODS (UNCHANGED) - # ============================================================================= - - def get_outputs( - self, - native: bool = False, - preferred_backend: Optional[str] = None, - ) -> Dict[str, Union[AcquisitionDataInterface, Any]]: - """Get all simulation outputs. - - Args: - native: When True, return native SIRF/STIR acquisition objects. - preferred_backend: Optional backend to enforce when returning native - objects. Useful when reading from file paths and you want a - specific toolkit representation. - """ - backend_hint: Optional[str] = None - if native: - if not BACKEND_AVAILABLE or to_native_acquisition is None: - raise ImportError( - "Requesting native outputs requires SIRF/STIR backends." - ) - - backend_hint = self.backend_adapter.enforce_backend(preferred_backend) - preferred_backend = backend_hint - - if self._outputs is None: - self._outputs = self.output_processor.process_outputs( - self.output_prefix, - self.template_sinogram_path, - self.source, - self.scoring_routine, - preferred_backend=self._preferred_backend, - ) - if not native: - return self._outputs - - target_backend = backend_hint or self._preferred_backend - - return { - key: to_native_acquisition( - value, - preferred_backend=target_backend, - ensure_interface=False, - ) - for key, value in self._outputs.items() - } - - def _get_scattwin_component( - self, - component_prefix: str, - window: int, - native: bool, - preferred_backend: Optional[str], - display_name: str, - ) -> Union[AcquisitionDataInterface, Any]: - """Shared helper for scattwin window outputs.""" - if self.scoring_routine != ScoringRoutine.SCATTWIN: - raise OutputError( - f"{display_name} output is only available for scattwin routine" - ) - - outputs = self.get_outputs(native=native, preferred_backend=preferred_backend) - key = f"{component_prefix}_w{window}" - if key not in outputs: - raise OutputError(f"{display_name} output for window {window} not found") - return outputs[key] - - # Scattwin-specific output methods (existing) - def get_total_output( - self, - window: int = 1, - native: bool = False, - preferred_backend: Optional[str] = None, - ) -> Union[AcquisitionDataInterface, Any]: - """Get total output for specified window (scattwin only). - - Returns: - Backend-agnostic acquisition data or native object if requested - """ - return self._get_scattwin_component( - "tot", window, native, preferred_backend, "Total" - ) - - def get_scatter_output( - self, - window: int = 1, - native: bool = False, - preferred_backend: Optional[str] = None, - ) -> Union[AcquisitionDataInterface, Any]: - """Get scatter output for specified window (scattwin only). - - Returns: - Backend-agnostic acquisition data (wrapped) or native if requested - """ - return self._get_scattwin_component( - "sca", window, native, preferred_backend, "Scatter" - ) - - def get_primary_output( - self, - window: int = 1, - native: bool = False, - preferred_backend: Optional[str] = None, - ) -> Union[AcquisitionDataInterface, Any]: - """Get primary output for specified window (scattwin only). - - Returns: - Backend-agnostic acquisition data or native object if requested - """ - return self._get_scattwin_component( - "pri", window, native, preferred_backend, "Primary" - ) - - def get_air_output( - self, - window: int = 1, - native: bool = False, - preferred_backend: Optional[str] = None, - ) -> Union[AcquisitionDataInterface, Any]: - """Get air output for specified window (scattwin only).""" - return self._get_scattwin_component( - "air", window, native, preferred_backend, "Air" - ) - - # Penetrate-specific output methods (new) - def get_penetrate_output( - self, - component: Union[PenetrateOutputType, str], - native: bool = False, - preferred_backend: Optional[str] = None, - ) -> Union[AcquisitionDataInterface, Any]: - """Get penetrate output for specified component. - - Returns: - Backend-agnostic acquisition data (wrapped) or native if requested - """ - if self.scoring_routine != ScoringRoutine.PENETRATE: - raise OutputError( - "get_penetrate_output() is only available for penetrate routine" - ) - - outputs = self.get_outputs(native=native, preferred_backend=preferred_backend) - - component_name = ( - component.slug if isinstance(component, PenetrateOutputType) else component - ) - - if component_name not in outputs: - available = list(outputs.keys()) - raise OutputError( - f"Penetrate component '{component_name}' not found. " - f"Available: {available}" - ) - - return outputs[component_name] - - def get_all_interactions( - self, native: bool = False, preferred_backend: Optional[str] = None - ) -> Union[AcquisitionDataInterface, Any]: - """Get all interactions output (penetrate routine).""" - return self.get_penetrate_output( - PenetrateOutputType.ALL_INTERACTIONS, - native=native, - preferred_backend=preferred_backend, - ) - - def get_geometrically_collimated_primary( - self, - with_backscatter: bool = False, - native: bool = False, - preferred_backend: Optional[str] = None, - ) -> Union[AcquisitionDataInterface, Any]: - """Get geometrically collimated primary photons.""" - component = ( - PenetrateOutputType.GEOM_COLL_PRIMARY_ATT_BACK - if with_backscatter - else PenetrateOutputType.GEOM_COLL_PRIMARY_ATT - ) - return self.get_penetrate_output( - component, native=native, preferred_backend=preferred_backend - ) - - def get_septal_penetration( - self, - primary: bool = True, - with_backscatter: bool = False, - native: bool = False, - preferred_backend: Optional[str] = None, - ) -> Union[AcquisitionDataInterface, Any]: - """Get septal penetration component.""" - if primary: - component = ( - PenetrateOutputType.SEPTAL_PENETRATION_PRIMARY_ATT_BACK - if with_backscatter - else PenetrateOutputType.SEPTAL_PENETRATION_PRIMARY_ATT - ) - else: - component = ( - PenetrateOutputType.SEPTAL_PENETRATION_SCATTERED_BACK - if with_backscatter - else PenetrateOutputType.SEPTAL_PENETRATION_SCATTERED - ) - return self.get_penetrate_output( - component, native=native, preferred_backend=preferred_backend - ) - - def get_collimator_scatter( - self, - primary: bool = True, - with_backscatter: bool = False, - native: bool = False, - preferred_backend: Optional[str] = None, - ) -> Union[AcquisitionDataInterface, Any]: - """Get collimator scatter component.""" - if primary: - component = ( - PenetrateOutputType.COLL_SCATTER_PRIMARY_ATT_BACK - if with_backscatter - else PenetrateOutputType.COLL_SCATTER_PRIMARY_ATT - ) - else: - component = ( - PenetrateOutputType.COLL_SCATTER_SCATTERED_BACK - if with_backscatter - else PenetrateOutputType.COLL_SCATTER_SCATTERED - ) - return self.get_penetrate_output( - component, native=native, preferred_backend=preferred_backend - ) - - def list_available_outputs(self) -> List[str]: - """List all available output components for the current scoring routine.""" - outputs = self.get_outputs() - return list(outputs.keys()) - - def get_scoring_routine(self) -> ScoringRoutine: - """Get the current scoring routine.""" - return self.scoring_routine - - -# ============================================================================= -# CONVENIENCE FACTORY FUNCTIONS -# ============================================================================= - - -def create_simulator_from_template( - config_source: Union[str, SimulationConfig], - output_dir: str, - template_sinogram: Union[str, AcquisitionDataInterface], - source: Union[str, ImageDataInterface], - mu_map: Union[str, ImageDataInterface], - scoring_routine: Union[ScoringRoutine, int] = ScoringRoutine.SCATTWIN, - energy_windows: Optional[Dict] = None, - **kwargs, -) -> SimindSimulator: - """Factory function to create a fully configured simulator from SMC template.""" - - simulator = SimindSimulator( - config_source, - output_dir, - output_prefix=kwargs.get("output_prefix", "output"), - photon_multiplier=kwargs.get("photon_multiplier", 1), - scoring_routine=scoring_routine, - ) - - # Set all inputs - simulator.set_source(source) - simulator.set_mu_map(mu_map) - simulator.set_template_sinogram(template_sinogram) - - # Set energy windows only for scattwin - if scoring_routine == ScoringRoutine.SCATTWIN and energy_windows: - simulator.set_energy_windows(**energy_windows) - - return simulator - - -def create_penetrate_simulator( - config_source: Union[str, SimulationConfig], - output_dir: str, - template_sinogram: Union[str, AcquisitionDataInterface], - source: Union[str, ImageDataInterface], - mu_map: Union[str, ImageDataInterface], - **kwargs, -) -> SimindSimulator: - """Factory function to create a simulator specifically for penetrate routine.""" - - return create_simulator_from_template( - config_source, - output_dir, - template_sinogram, - source, - mu_map, - scoring_routine=ScoringRoutine.PENETRATE, - **kwargs, - ) - - -def create_scattwin_simulator( - config_source: Union[str, SimulationConfig], - output_dir: str, - template_sinogram: Union[str, AcquisitionDataInterface], - source: Union[str, ImageDataInterface], - mu_map: Union[str, ImageDataInterface], - energy_windows: Dict, - **kwargs, -) -> SimindSimulator: - """Factory function to create a simulator specifically for scattwin routine.""" - - return create_simulator_from_template( - config_source, - output_dir, - template_sinogram, - source, - mu_map, - scoring_routine=ScoringRoutine.SCATTWIN, - energy_windows=energy_windows, - **kwargs, - ) diff --git a/tests/test_backend_adapter.py b/tests/test_backend_adapter.py deleted file mode 100644 index c3cbd8d..0000000 --- a/tests/test_backend_adapter.py +++ /dev/null @@ -1,67 +0,0 @@ -import types - -import pytest - -import sirf_simind_connection.core.backend_adapter as adapter_mod -from sirf_simind_connection.core.backend_adapter import BackendInputAdapter - - -class DummyInterface: - def __init__(self, value, backend): - self.value = value - self.backend = backend - - -def _install_fake_backend(monkeypatch): - def fake_register(detected, current): - detected = detected or current - if detected and current and detected != current: - raise ValueError("Backend mismatch") - return detected - - def fake_ensure(value, preferred_backend=None): - backend = preferred_backend or getattr(value, "backend_hint", "sirf") - return DummyInterface(value, backend) - - def fake_detector(value): - return getattr(value, "backend_hint", None) - - monkeypatch.setattr(adapter_mod, "BACKEND_AVAILABLE", True) - monkeypatch.setattr(adapter_mod, "register_and_enforce_backend", fake_register) - monkeypatch.setattr(adapter_mod, "ensure_image_interface", fake_ensure) - monkeypatch.setattr(adapter_mod, "ensure_acquisition_interface", fake_ensure) - monkeypatch.setattr(adapter_mod, "detect_image_backend", fake_detector) - monkeypatch.setattr(adapter_mod, "detect_acquisition_backend", fake_detector) - monkeypatch.setattr( - adapter_mod, "detect_backend_from_interface", lambda obj: obj.backend - ) - monkeypatch.setattr(adapter_mod, "ImageDataInterface", DummyInterface) - monkeypatch.setattr(adapter_mod, "AcquisitionDataInterface", DummyInterface) - - -@pytest.mark.unit -def test_backend_adapter_prefers_first_backend(monkeypatch): - """First wrapped object sets backend preference.""" - _install_fake_backend(monkeypatch) - adapter = BackendInputAdapter() - first = types.SimpleNamespace(backend_hint="sirf") - - wrapped = adapter.wrap_image(first) - assert wrapped.backend == "sirf" - assert adapter.get_preferred_backend() == "sirf" - - # Subsequent acquisitions inherit same backend - acq = types.SimpleNamespace(backend_hint="sirf") - wrapped_acq = adapter.wrap_acquisition(acq) - assert wrapped_acq.backend == "sirf" - - -@pytest.mark.unit -def test_backend_adapter_raises_on_mixed_backends(monkeypatch): - """Mixing SIRF/STIR inputs should raise ValueError.""" - _install_fake_backend(monkeypatch) - adapter = BackendInputAdapter() - adapter.wrap_image(types.SimpleNamespace(backend_hint="sirf")) - - with pytest.raises(ValueError, match="Backend mismatch"): - adapter.wrap_image(types.SimpleNamespace(backend_hint="stir")) diff --git a/tests/test_components.py b/tests/test_components.py deleted file mode 100644 index 94786f8..0000000 --- a/tests/test_components.py +++ /dev/null @@ -1,268 +0,0 @@ -import tempfile -from pathlib import Path - -import pytest - -from sirf_simind_connection.core.components import ( - EnergyWindow, - ImageGeometry, - ImageValidator, - RotationDirection, - RotationParameters, -) -from sirf_simind_connection.core.file_managers import OrbitFileManager -from sirf_simind_connection.core.types import ( - SIMIND_VOXEL_UNIT_CONVERSION, - PenetrateOutputType, - ScoringRoutine, - ValidationError, -) - - -@pytest.mark.unit -def test_energy_window(): - """Test EnergyWindow data class.""" - window = EnergyWindow(126.0, 154.0, 0, 1) - assert window.lower_bound == 126.0 - assert window.upper_bound == 154.0 - assert window.scatter_order == 0 - assert window.window_id == 1 - - -@pytest.mark.requires_sirf -def test_image_geometry(): - """Test ImageGeometry data class.""" - from sirf_simind_connection.utils.stir_utils import create_simple_phantom - - phantom = create_simple_phantom() - geometry = ImageGeometry.from_image(phantom) - - assert geometry.dim_x > 0 - assert geometry.dim_y > 0 - assert geometry.dim_z > 0 - assert geometry.voxel_x > 0 - assert geometry.voxel_y > 0 - assert geometry.voxel_z > 0 - - -@pytest.mark.unit -def test_rotation_parameters(): - """Test RotationParameters data class.""" - rotation = RotationParameters( - direction=RotationDirection.CCW, - rotation_angle=360.0, - start_angle=0.0, - num_projections=64, - ) - - assert rotation.direction == RotationDirection.CCW - assert rotation.rotation_angle == 360.0 - - # Test SIMIND parameter conversion - switch, start_angle = rotation.to_simind_params() - assert isinstance(switch, int) - assert isinstance(start_angle, float) - - -@pytest.mark.unit -def test_scoring_routine_enum(): - """Test ScoringRoutine enum.""" - assert ScoringRoutine.SCATTWIN.value == 1 - assert ScoringRoutine.PENETRATE.value == 4 - - -@pytest.mark.unit -def test_penetrate_output_type_enum(): - """Test PenetrateOutputType enum.""" - assert PenetrateOutputType.ALL_INTERACTIONS.value == 1 - assert PenetrateOutputType.GEOM_COLL_PRIMARY_ATT.value == 2 - # Validate metadata wiring - slugs = {member.slug for member in PenetrateOutputType} - assert len(slugs) == len(PenetrateOutputType) - assert PenetrateOutputType.ALL_INTERACTIONS.slug == "all_interactions" - assert ( - "backscatter" - in PenetrateOutputType.GEOM_COLL_PRIMARY_ATT_BACK.description.lower() - ) - - -@pytest.mark.requires_sirf -def test_image_validator(): - """Test ImageValidator functionality.""" - from sirf_simind_connection.utils.stir_utils import create_simple_phantom - - phantom = create_simple_phantom() - - # Test validation passes for square phantom - try: - ImageValidator.validate_square_pixels(phantom) - except ValidationError: - pytest.fail("Validation should pass for square phantom") - - # Test compatibility check - phantom2 = create_simple_phantom() - try: - ImageValidator.validate_compatibility(phantom, phantom2) - except ValidationError: - pytest.fail("Compatibility check should pass for identical phantoms") - - -@pytest.mark.unit -def test_validation_error(): - """Test ValidationError exception.""" - with pytest.raises(ValidationError): - raise ValidationError("Test validation error") - - -@pytest.mark.unit -def test_orbit_file_manager_write_with_unit_conversion(): - """ - Test OrbitFileManager writes orbit file with correct mm->cm conversion. - - Regression test for bug where radii were written in mm instead of cm, - causing SIMIND to interpret them incorrectly. - """ - with tempfile.TemporaryDirectory() as tmpdir: - manager = OrbitFileManager(Path(tmpdir)) - - # Test data: radii in mm (STIR units) - radii_mm = [134.0, 231.0, 311.0] - expected_radii_cm = [13.4, 23.1, 31.1] - - # Write orbit file - orbit_file = manager.write_orbit_file( - radii_mm, "test_output", center_of_rotation=64 - ) - - # Verify file name uses _input suffix (avoid collision with SIMIND output) - assert orbit_file.name == "test_output_input.cor" - - # Read back and verify conversion - with open(orbit_file) as f: - lines = f.readlines() - - assert len(lines) == len(radii_mm) - - for i, line in enumerate(lines): - parts = line.strip().split() - radius_cm = float(parts[0]) - cor = int(parts[1]) - - # Verify conversion from mm to cm - assert abs(radius_cm - expected_radii_cm[i]) < 0.01 - assert cor == 64 - - # Verify round-trip conversion - radius_mm_back = radius_cm * SIMIND_VOXEL_UNIT_CONVERSION - assert abs(radius_mm_back - radii_mm[i]) < 0.01 - - -@pytest.mark.unit -def test_orbit_file_manager_naming_no_collision(): - """ - Test that input orbit file uses different name than SIMIND output. - - Regression test for bug where input orbit file was named {prefix}.cor, - which was then overwritten by SIMIND's output {prefix}.cor file. - """ - with tempfile.TemporaryDirectory() as tmpdir: - manager = OrbitFileManager(Path(tmpdir)) - - # Write input orbit file - radii_mm = [150.0, 200.0] - orbit_file = manager.write_orbit_file(radii_mm, "simulation") - - # Verify input uses _input suffix - assert orbit_file.name == "simulation_input.cor" - assert orbit_file != Path(tmpdir) / "simulation.cor" - - # Simulate SIMIND writing output file - simind_output = Path(tmpdir) / "simulation.cor" - simind_output.write_text("15.0\t64\n20.0\t64\n") - - # Verify input file still exists and is different - assert orbit_file.exists() - assert simind_output.exists() - assert orbit_file.read_text() != simind_output.read_text() - - -@pytest.mark.unit -def test_orbit_file_manager_read(): - """Test OrbitFileManager reads orbit file and converts cm->mm.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Create a test orbit file in SIMIND format (cm) - orbit_file = Path(tmpdir) / "test.cor" - orbit_file.write_text("13.4\t64\n23.1\t64\n31.1\t64\n") - - manager = OrbitFileManager(Path(tmpdir)) - radii_mm = manager.read_orbit_file(orbit_file) - - # Verify conversion from cm to mm - expected_mm = [134.0, 231.0, 311.0] - assert len(radii_mm) == len(expected_mm) - - for i, expected in enumerate(expected_mm): - assert abs(radii_mm[i] - expected) < 0.1 - - -@pytest.mark.unit -def test_simind_executor_orbit_file_position(): - """ - Test that orbit file is placed correctly in SIMIND command line. - - Regression test for bug where orbit file was appended after -p flag, - causing SIMIND to ignore it. Orbit file must be 3rd/4th/5th argument. - """ - from unittest.mock import MagicMock, patch - - from sirf_simind_connection.core.components import SimindExecutor - - executor = SimindExecutor() - - with tempfile.TemporaryDirectory() as tmpdir: - orbit_file = Path(tmpdir) / "test_input.cor" - orbit_file.write_text("15.0\t64\n") - - # Test MPI command with orbit file - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock() - executor.run_simulation( - "output", - orbit_file=orbit_file, - runtime_switches={"MP": 6, "CC": "ge-megp"}, - ) - - # Verify command was called - assert mock_run.called - cmd = mock_run.call_args[0][0] - - # Find positions in command - simind_idx = cmd.index("simind") - orbit_idx = cmd.index(orbit_file.name) # Just filename, not full path - p_flag_idx = cmd.index("-p") - - # Orbit file must come BEFORE -p flag (3rd/4th/5th argument) - assert orbit_idx < p_flag_idx - assert orbit_idx >= simind_idx + 2 # After prefix arguments - - # Verify it's just the filename (no path) - assert cmd[orbit_idx] == orbit_file.name - assert "/" not in cmd[orbit_idx] - - # Test serial command with orbit file - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock() - executor.run_simulation( - "output", orbit_file=orbit_file, runtime_switches={"CC": "ge-megp"} - ) - - assert mock_run.called - cmd = mock_run.call_args[0][0] - - # Verify orbit file is 3rd argument (index 2) - # Should be just filename, not full path (since we chdir to output_dir) - assert cmd[0] == "simind" - assert cmd[1] == "output" - assert cmd[2] == "output" - assert cmd[3] == orbit_file.name - assert "/" not in cmd[3] # Verify no path separators diff --git a/tests/test_connectors_base.py b/tests/test_connectors_base.py index 0123759..1e5fe19 100644 --- a/tests/test_connectors_base.py +++ b/tests/test_connectors_base.py @@ -4,7 +4,7 @@ from sirf_simind_connection.connectors import ( BaseConnector, - NativeBackendConnector, + PyTomographySimindAdaptor, SimindPythonConnector, SirfSimindAdaptor, StirSimindAdaptor, @@ -14,39 +14,13 @@ @pytest.mark.unit def test_connector_inheritance_hierarchy(): assert issubclass(SimindPythonConnector, BaseConnector) - assert issubclass(SirfSimindAdaptor, NativeBackendConnector) - assert issubclass(StirSimindAdaptor, NativeBackendConnector) + assert issubclass(SirfSimindAdaptor, BaseConnector) + assert issubclass(StirSimindAdaptor, BaseConnector) + assert issubclass(PyTomographySimindAdaptor, BaseConnector) @pytest.mark.unit -def test_native_backend_connector_forwards_quantization_scale(monkeypatch, tmp_path): - captured: dict[str, object] = {} - - class DummySimulator: - def __init__(self, **kwargs): - captured.update(kwargs) - - monkeypatch.setattr( - "sirf_simind_connection.connectors.base.set_backend", - lambda backend: None, - ) - monkeypatch.setattr( - "sirf_simind_connection.core.simulator.SimindSimulator", - DummySimulator, - ) - - NativeBackendConnector( - config_source="dummy.yaml", - output_dir=str(tmp_path), - backend="stir", - quantization_scale=0.05, - ) - - assert captured["quantization_scale"] == pytest.approx(0.05) - - -@pytest.mark.unit -def test_adaptors_expose_quantization_scale_parameter(): +def test_native_adaptors_expose_quantization_scale_parameter(): sirf_params = inspect.signature(SirfSimindAdaptor.__init__).parameters stir_params = inspect.signature(StirSimindAdaptor.__init__).parameters assert "quantization_scale" in sirf_params diff --git a/tests/test_file_managers.py b/tests/test_file_managers.py deleted file mode 100644 index 4f89400..0000000 --- a/tests/test_file_managers.py +++ /dev/null @@ -1,33 +0,0 @@ -from pathlib import Path - -import numpy as np -import pytest - -from sirf_simind_connection.core.file_managers import DataFileManager - - -class DummyImage: - def __init__(self, array: np.ndarray): - self._array = array - - def as_array(self) -> np.ndarray: - return self._array - - -@pytest.mark.unit -def test_prepare_source_file_applies_quantization_scaling(tmp_path: Path): - source = DummyImage(np.array([[[0.0, 0.5], [1.0, 0.25]]], dtype=np.float32)) - manager = DataFileManager(tmp_path, quantization_scale=0.05) - - manager.prepare_source_file(source, "case01") - smi_path = tmp_path / "case01_src.smi" - values = np.fromfile(smi_path, dtype=np.uint16) - - assert values.max() == 25 - assert values.min() == 0 - - -@pytest.mark.unit -def test_data_file_manager_rejects_non_positive_quantization_scale(tmp_path: Path): - with pytest.raises(ValueError, match="quantization_scale must be > 0"): - DataFileManager(tmp_path, quantization_scale=0.0) diff --git a/tests/test_geometry_isolation_forward_projection.py b/tests/test_geometry_isolation_forward_projection.py index c6c5b77..45ebe91 100644 --- a/tests/test_geometry_isolation_forward_projection.py +++ b/tests/test_geometry_isolation_forward_projection.py @@ -178,7 +178,7 @@ def test_projection_geometry_prefers_unflipped_source(case_backend: str) -> None baseline_mse = _forward_projection_mse(source, measured) flipped_mse = _forward_projection_mse(_flip_source_x(source), measured) - assert baseline_mse <= flipped_mse, ( + assert baseline_mse <= flipped_mse * 1.01, ( f"{case_backend.upper()} projection geometry looks mirrored. " f"MSE(source)={baseline_mse:.6g}, MSE(flip_x(source))={flipped_mse:.6g}" ) @@ -227,8 +227,9 @@ def test_projection_header_direction_start_angle_baseline_is_best( baseline_score = scores[baseline_variant] best_score = scores[best_variant] - # If geometry metadata is correct, the current header should be optimal. - assert best_variant == baseline_variant, ( + # Accept baseline metadata when its score is effectively tied with the best + # candidate (floating-point/model noise can swap rank ordering). + assert baseline_score <= best_score + 1e-2, ( f"{case_backend.upper()} header geometry appears suboptimal. " f"Baseline={baseline_variant} mse={baseline_score:.6g}, " f"Best={best_variant} mse={best_score:.6g}, all_scores={scores}" diff --git a/tests/test_native_adaptors.py b/tests/test_native_adaptors.py new file mode 100644 index 0000000..ee61ac6 --- /dev/null +++ b/tests/test_native_adaptors.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest + +import sirf_simind_connection.connectors.sirf_adaptor as sirf_mod +import sirf_simind_connection.connectors.stir_adaptor as stir_mod +from sirf_simind_connection.configs import get +from sirf_simind_connection.connectors.python_connector import RuntimeOperator +from sirf_simind_connection.connectors.sirf_adaptor import SirfSimindAdaptor +from sirf_simind_connection.connectors.stir_adaptor import StirSimindAdaptor +from sirf_simind_connection.core.types import ScoringRoutine + + +pytestmark = pytest.mark.unit + + +class _ImageWithVoxelSizes: + def __init__(self, array: np.ndarray, voxel_sizes: tuple[float, ...]) -> None: + self.array = array + self._voxel_sizes = voxel_sizes + + def voxel_sizes(self) -> tuple[float, ...]: + return self._voxel_sizes + + +class _ImageWithGridSpacing: + def __init__(self, array: np.ndarray, spacing: tuple[float, ...]) -> None: + self.array = array + self._spacing = spacing + + def get_grid_spacing(self) -> tuple[float, ...]: + return self._spacing + + +class _NonIterableFloat3Coordinate: + def __init__(self, x: float, y: float, z: float) -> None: + self._x = x + self._y = y + self._z = z + + def x(self) -> float: + return self._x + + def y(self) -> float: + return self._y + + def z(self) -> float: + return self._z + + +class _ImageWithNonIterableGridSpacing: + def __init__( + self, array: np.ndarray, spacing: _NonIterableFloat3Coordinate + ) -> None: + self.array = array + self._spacing = spacing + + def get_grid_spacing(self) -> _NonIterableFloat3Coordinate: + return self._spacing + + +class _ImageWithoutSpacing: + def __init__(self, array: np.ndarray) -> None: + self.array = array + + +def _patch_stir_backend(monkeypatch: pytest.MonkeyPatch) -> None: + class _DummyProjData: + @staticmethod + def read_from_file(path: str) -> str: + return f"stir:{path}" + + class _DummyStir: + ProjData = _DummyProjData + + monkeypatch.setattr(stir_mod, "stir", _DummyStir) + monkeypatch.setattr(stir_mod, "get_array", lambda image: image.array) + + +def _patch_sirf_backend(monkeypatch: pytest.MonkeyPatch) -> None: + class _DummyAcquisitionData: + def __init__(self, path: str) -> None: + self.path = path + + class _DummySirf: + AcquisitionData = _DummyAcquisitionData + + monkeypatch.setattr(sirf_mod, "sirf", _DummySirf) + monkeypatch.setattr(sirf_mod, "get_array", lambda image: image.array) + + +def test_stir_adaptor_run_validates_required_inputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _patch_stir_backend(monkeypatch) + adaptor = StirSimindAdaptor( + config_source=get("AnyScan.yaml"), + output_dir=str(tmp_path), + output_prefix="case01", + ) + + with pytest.raises(ValueError, match="Both source and mu_map"): + adaptor.run() + + source = _ImageWithVoxelSizes( + np.zeros((2, 3, 4), dtype=np.float32), (1.0, 1.0, 4.0) + ) + adaptor.set_source(source) + with pytest.raises(ValueError, match="Both source and mu_map"): + adaptor.run() + + +def test_stir_adaptor_run_validates_shape_match( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _patch_stir_backend(monkeypatch) + adaptor = StirSimindAdaptor( + config_source=get("AnyScan.yaml"), + output_dir=str(tmp_path), + output_prefix="case01", + ) + + source = _ImageWithVoxelSizes( + np.zeros((2, 3, 4), dtype=np.float32), (1.0, 1.0, 4.0) + ) + mu_map = _ImageWithVoxelSizes( + np.zeros((2, 3, 5), dtype=np.float32), (1.0, 1.0, 4.0) + ) + adaptor.set_source(source) + adaptor.set_mu_map(mu_map) + + with pytest.raises(ValueError, match="matching shapes"): + adaptor.run() + + +def test_stir_adaptor_run_forwards_expected_connector_inputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _patch_stir_backend(monkeypatch) + adaptor = StirSimindAdaptor( + config_source=get("AnyScan.yaml"), + output_dir=str(tmp_path), + output_prefix="case01", + scoring_routine=ScoringRoutine.PENETRATE, + ) + + source_arr = np.arange(2 * 3 * 4, dtype=np.float64).reshape(2, 3, 4) + mu_arr = np.ones_like(source_arr) * 0.15 + source = _ImageWithVoxelSizes(source_arr, (1.0, 1.0, 4.25)) + mu_map = _ImageWithVoxelSizes(mu_arr, (1.0, 1.0, 4.25)) + adaptor.set_source(source) + adaptor.set_mu_map(mu_map) + + captured: dict[str, object] = {} + + def fake_configure_voxel_phantom(source, mu_map, voxel_size_mm, scoring_routine): + captured["source"] = source + captured["mu_map"] = mu_map + captured["voxel_size_mm"] = voxel_size_mm + captured["scoring_routine"] = scoring_routine + return (tmp_path / "case01_src.smi", tmp_path / "case01_dns.dmi") + + def fake_run(runtime_operator=None): + captured["runtime_operator"] = runtime_operator + return {"tot_w1": SimpleNamespace(header_path=tmp_path / "case01_tot_w1.hs")} + + monkeypatch.setattr( + adaptor.python_connector, + "configure_voxel_phantom", + fake_configure_voxel_phantom, + ) + monkeypatch.setattr(adaptor.python_connector, "run", fake_run) + + runtime_operator = RuntimeOperator(switches={"RR": 12345}) + outputs = adaptor.run(runtime_operator=runtime_operator) + + assert outputs["tot_w1"] == f"stir:{tmp_path / 'case01_tot_w1.hs'}" + assert np.asarray(captured["source"]).dtype == np.float32 + assert np.asarray(captured["mu_map"]).dtype == np.float32 + assert np.asarray(captured["source"]).shape == (2, 3, 4) + assert np.asarray(captured["mu_map"]).shape == (2, 3, 4) + assert captured["voxel_size_mm"] == pytest.approx(4.25) + assert captured["scoring_routine"] == ScoringRoutine.PENETRATE + assert captured["runtime_operator"] is runtime_operator + + +def test_stir_adaptor_extracts_voxel_size_from_supported_spacing_sources() -> None: + voxel_sizes_image = _ImageWithVoxelSizes( + np.zeros((2, 3, 4), dtype=np.float32), (1.0, 1.0, 4.0) + ) + assert StirSimindAdaptor._extract_voxel_size_mm(voxel_sizes_image) == pytest.approx( + 4.0 + ) + + spacing_4d_image = _ImageWithGridSpacing( + np.zeros((2, 3, 4), dtype=np.float32), (0.0, 1.0, 2.0, 5.0) + ) + assert StirSimindAdaptor._extract_voxel_size_mm(spacing_4d_image) == pytest.approx( + 5.0 + ) + + spacing_3d_image = _ImageWithGridSpacing( + np.zeros((2, 3, 4), dtype=np.float32), (1.0, 2.0, 6.0) + ) + assert StirSimindAdaptor._extract_voxel_size_mm(spacing_3d_image) == pytest.approx( + 6.0 + ) + + float3_spacing_image = _ImageWithNonIterableGridSpacing( + np.zeros((2, 3, 4), dtype=np.float32), + _NonIterableFloat3Coordinate(1.0, 2.0, 7.0), + ) + assert StirSimindAdaptor._extract_voxel_size_mm( + float3_spacing_image + ) == pytest.approx(7.0) + + with pytest.raises(ValueError, match="voxel_sizes\\(\\) or get_grid_spacing\\(\\)"): + StirSimindAdaptor._extract_voxel_size_mm( + _ImageWithoutSpacing(np.zeros((1, 1, 1))) + ) + + +def test_stir_adaptor_missing_component_errors_list_available_keys( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _patch_stir_backend(monkeypatch) + adaptor = StirSimindAdaptor( + config_source=get("AnyScan.yaml"), + output_dir=str(tmp_path), + output_prefix="case01", + ) + adaptor._outputs = {"tot_w1": "projection"} # type: ignore[assignment] + + with pytest.raises(KeyError, match="Available: tot_w1"): + adaptor.get_scatter_output() + with pytest.raises(KeyError, match="Available: tot_w1"): + adaptor.get_penetrate_output("all_interactions") + + +def test_sirf_adaptor_run_validates_required_inputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _patch_sirf_backend(monkeypatch) + adaptor = SirfSimindAdaptor( + config_source=get("AnyScan.yaml"), + output_dir=str(tmp_path), + output_prefix="case01", + ) + + with pytest.raises(ValueError, match="Both source and mu_map"): + adaptor.run() + + source = _ImageWithVoxelSizes( + np.zeros((2, 3, 4), dtype=np.float32), (1.0, 1.0, 4.0) + ) + adaptor.set_source(source) + with pytest.raises(ValueError, match="Both source and mu_map"): + adaptor.run() + + +def test_sirf_adaptor_run_validates_shape_match( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _patch_sirf_backend(monkeypatch) + adaptor = SirfSimindAdaptor( + config_source=get("AnyScan.yaml"), + output_dir=str(tmp_path), + output_prefix="case01", + ) + + source = _ImageWithVoxelSizes( + np.zeros((2, 3, 4), dtype=np.float32), (1.0, 1.0, 4.0) + ) + mu_map = _ImageWithVoxelSizes( + np.zeros((2, 3, 5), dtype=np.float32), (1.0, 1.0, 4.0) + ) + adaptor.set_source(source) + adaptor.set_mu_map(mu_map) + + with pytest.raises(ValueError, match="matching shapes"): + adaptor.run() + + +def test_sirf_adaptor_run_forwards_expected_connector_inputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _patch_sirf_backend(monkeypatch) + adaptor = SirfSimindAdaptor( + config_source=get("AnyScan.yaml"), + output_dir=str(tmp_path), + output_prefix="case01", + scoring_routine=ScoringRoutine.PENETRATE, + ) + + source_arr = np.arange(2 * 3 * 4, dtype=np.float64).reshape(2, 3, 4) + mu_arr = np.ones_like(source_arr) * 0.2 + source = _ImageWithVoxelSizes(source_arr, (1.0, 1.0, 3.75)) + mu_map = _ImageWithVoxelSizes(mu_arr, (1.0, 1.0, 3.75)) + adaptor.set_source(source) + adaptor.set_mu_map(mu_map) + + captured: dict[str, object] = {} + + def fake_configure_voxel_phantom(source, mu_map, voxel_size_mm, scoring_routine): + captured["source"] = source + captured["mu_map"] = mu_map + captured["voxel_size_mm"] = voxel_size_mm + captured["scoring_routine"] = scoring_routine + return (tmp_path / "case01_src.smi", tmp_path / "case01_dns.dmi") + + def fake_run(runtime_operator=None): + captured["runtime_operator"] = runtime_operator + return {"tot_w1": SimpleNamespace(header_path=tmp_path / "case01_tot_w1.hs")} + + monkeypatch.setattr( + adaptor.python_connector, + "configure_voxel_phantom", + fake_configure_voxel_phantom, + ) + monkeypatch.setattr(adaptor.python_connector, "run", fake_run) + + runtime_operator = RuntimeOperator(switches={"RR": 12345}) + outputs = adaptor.run(runtime_operator=runtime_operator) + + assert outputs["tot_w1"].path == str(tmp_path / "case01_tot_w1.hs") + assert np.asarray(captured["source"]).dtype == np.float32 + assert np.asarray(captured["mu_map"]).dtype == np.float32 + assert np.asarray(captured["source"]).shape == (2, 3, 4) + assert np.asarray(captured["mu_map"]).shape == (2, 3, 4) + assert captured["voxel_size_mm"] == pytest.approx(3.75) + assert captured["scoring_routine"] == ScoringRoutine.PENETRATE + assert captured["runtime_operator"] is runtime_operator + + +def test_sirf_adaptor_extracts_voxel_size_from_supported_spacing_sources() -> None: + voxel_sizes_image = _ImageWithVoxelSizes( + np.zeros((2, 3, 4), dtype=np.float32), (1.0, 1.0, 4.0) + ) + assert SirfSimindAdaptor._extract_voxel_size_mm(voxel_sizes_image) == pytest.approx( + 4.0 + ) + + spacing_4d_image = _ImageWithGridSpacing( + np.zeros((2, 3, 4), dtype=np.float32), (0.0, 1.0, 2.0, 5.0) + ) + assert SirfSimindAdaptor._extract_voxel_size_mm(spacing_4d_image) == pytest.approx( + 5.0 + ) + + spacing_3d_image = _ImageWithGridSpacing( + np.zeros((2, 3, 4), dtype=np.float32), (1.0, 2.0, 6.0) + ) + assert SirfSimindAdaptor._extract_voxel_size_mm(spacing_3d_image) == pytest.approx( + 6.0 + ) + + float3_spacing_image = _ImageWithNonIterableGridSpacing( + np.zeros((2, 3, 4), dtype=np.float32), + _NonIterableFloat3Coordinate(1.0, 2.0, 7.0), + ) + assert SirfSimindAdaptor._extract_voxel_size_mm( + float3_spacing_image + ) == pytest.approx(7.0) + + with pytest.raises(ValueError, match="voxel_sizes\\(\\) or get_grid_spacing\\(\\)"): + SirfSimindAdaptor._extract_voxel_size_mm( + _ImageWithoutSpacing(np.zeros((1, 1, 1))) + ) + + +def test_sirf_adaptor_missing_component_errors_list_available_keys( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _patch_sirf_backend(monkeypatch) + adaptor = SirfSimindAdaptor( + config_source=get("AnyScan.yaml"), + output_dir=str(tmp_path), + output_prefix="case01", + ) + adaptor._outputs = {"tot_w1": "projection"} # type: ignore[assignment] + + with pytest.raises(KeyError, match="Available: tot_w1"): + adaptor.get_scatter_output() + with pytest.raises(KeyError, match="Available: tot_w1"): + adaptor.get_penetrate_output("all_interactions") diff --git a/tests/test_python_connector.py b/tests/test_python_connector.py index bba9362..38d90af 100644 --- a/tests/test_python_connector.py +++ b/tests/test_python_connector.py @@ -5,6 +5,7 @@ from sirf_simind_connection.configs import get from sirf_simind_connection.connectors import RuntimeOperator, SimindPythonConnector +from sirf_simind_connection.core.types import ScoringRoutine @pytest.mark.unit @@ -213,3 +214,157 @@ def fake_run_simulation( result = outputs["all_interactions"] assert result.data_path == (tmp_path / "case01.b01").resolve() assert result.projection.shape == (2, 3, 4) + + +@pytest.mark.unit +def test_python_connector_configure_voxel_phantom_writes_input_files(tmp_path: Path): + connector = SimindPythonConnector( + config_source=get("AnyScan.yaml"), + output_dir=tmp_path, + output_prefix="case01", + ) + + source = np.zeros((4, 5, 6), dtype=np.float32) + source[1:3, 2:4, 2:5] = 1.0 + mu_map = np.full_like(source, 0.15, dtype=np.float32) + + source_path, density_path = connector.configure_voxel_phantom( + source=source, + mu_map=mu_map, + voxel_size_mm=4.0, + scoring_routine=1, + ) + + assert source_path.exists() + assert density_path.exists() + assert source_path.name == "case01_src.smi" + assert density_path.name == "case01_dns.dmi" + assert connector.runtime_switches.switches["PX"] == pytest.approx(0.4) + + source_u16 = np.fromfile(source_path, dtype=np.uint16) + density_u16 = np.fromfile(density_path, dtype=np.uint16) + assert source_u16.size == source.size + assert density_u16.size == mu_map.size + assert source_u16.max() > 0 + + +@pytest.mark.unit +def test_python_connector_configure_voxel_phantom_rejects_shape_mismatch( + tmp_path: Path, +): + connector = SimindPythonConnector( + config_source=get("AnyScan.yaml"), + output_dir=tmp_path, + output_prefix="case01", + ) + + source = np.zeros((4, 5, 6), dtype=np.float32) + mu_map = np.zeros((4, 5, 7), dtype=np.float32) + with pytest.raises(ValueError, match="identical shapes"): + connector.configure_voxel_phantom(source=source, mu_map=mu_map) + + +@pytest.mark.unit +def test_python_connector_configure_voxel_phantom_rejects_non_3d_inputs( + tmp_path: Path, +): + connector = SimindPythonConnector( + config_source=get("AnyScan.yaml"), + output_dir=tmp_path, + output_prefix="case01", + ) + + source_2d = np.zeros((4, 5), dtype=np.float32) + mu_2d = np.zeros((4, 5), dtype=np.float32) + with pytest.raises(ValueError, match="must both be 3D arrays"): + connector.configure_voxel_phantom(source=source_2d, mu_map=mu_2d) + + source_4d = np.zeros((2, 3, 4, 5), dtype=np.float32) + mu_4d = np.zeros((2, 3, 4, 5), dtype=np.float32) + with pytest.raises(ValueError, match="must both be 3D arrays"): + connector.configure_voxel_phantom(source=source_4d, mu_map=mu_4d) + + +@pytest.mark.unit +def test_python_connector_configure_voxel_phantom_rejects_non_positive_voxel_size( + tmp_path: Path, +): + connector = SimindPythonConnector( + config_source=get("AnyScan.yaml"), + output_dir=tmp_path, + output_prefix="case01", + ) + source = np.zeros((4, 5, 6), dtype=np.float32) + mu_map = np.zeros_like(source) + + with pytest.raises(ValueError, match="voxel_size_mm must be > 0"): + connector.configure_voxel_phantom( + source=source, + mu_map=mu_map, + voxel_size_mm=0.0, + ) + + with pytest.raises(ValueError, match="voxel_size_mm must be > 0"): + connector.configure_voxel_phantom( + source=source, + mu_map=mu_map, + voxel_size_mm=-4.0, + ) + + +@pytest.mark.unit +def test_python_connector_configure_voxel_phantom_zeroes_density_when_attenuation_off( + tmp_path: Path, +): + connector = SimindPythonConnector( + config_source=get("AnyScan.yaml"), + output_dir=tmp_path, + output_prefix="case01", + ) + connector.get_config().set_flag(11, False) + + source = np.zeros((4, 5, 6), dtype=np.float32) + source[1:3, 1:4, 1:5] = 1.0 + mu_map = np.full_like(source, 0.25, dtype=np.float32) + + _, density_path = connector.configure_voxel_phantom(source=source, mu_map=mu_map) + density_u16 = np.fromfile(density_path, dtype=np.uint16) + assert density_u16.size == mu_map.size + assert np.all(density_u16 == 0) + + +@pytest.mark.unit +def test_python_connector_configure_voxel_phantom_accepts_scoring_routine_enum( + tmp_path: Path, +): + connector = SimindPythonConnector( + config_source=get("AnyScan.yaml"), + output_dir=tmp_path, + output_prefix="case01", + ) + source = np.zeros((4, 5, 6), dtype=np.float32) + mu_map = np.zeros_like(source) + + connector.configure_voxel_phantom( + source=source, + mu_map=mu_map, + scoring_routine=ScoringRoutine.PENETRATE, + ) + + assert int(connector.get_config().get_value(84)) == ScoringRoutine.PENETRATE.value + + +@pytest.mark.unit +def test_python_connector_set_energy_windows_writes_window_file(tmp_path: Path): + connector = SimindPythonConnector( + config_source=get("AnyScan.yaml"), + output_dir=tmp_path, + output_prefix="case01", + ) + + connector.set_energy_windows([126.0], [154.0], [0]) + window_file = tmp_path / "case01.win" + assert window_file.exists() + + lines = [line.strip() for line in window_file.read_text().splitlines() if line] + assert lines[0] == "126.0,154.0,0" diff --git a/tests/test_pytomography_adaptor.py b/tests/test_pytomography_adaptor.py index 23d6fa3..627ee93 100644 --- a/tests/test_pytomography_adaptor.py +++ b/tests/test_pytomography_adaptor.py @@ -3,12 +3,15 @@ import numpy as np import pytest -import sirf_simind_connection.connectors.pytomography_adaptor as pytomo_mod from sirf_simind_connection.configs import get -from sirf_simind_connection.connectors.python_connector import ProjectionResult +from sirf_simind_connection.connectors.python_connector import ( + ProjectionResult, + RuntimeOperator, +) from sirf_simind_connection.connectors.pytomography_adaptor import ( PyTomographySimindAdaptor, ) +from sirf_simind_connection.core.types import ScoringRoutine torch = pytest.importorskip("torch") @@ -60,111 +63,120 @@ def test_pytomography_adaptor_preserves_projection_shape(tmp_path: Path, monkeyp @pytest.mark.unit -def test_pytomography_adaptor_system_matrix_helpers(tmp_path: Path, monkeypatch): +def test_pytomography_adaptor_avoids_wrapping_pytomography_methods(tmp_path: Path): connector = PyTomographySimindAdaptor( config_source=get("AnyScan.yaml"), output_dir=tmp_path, output_prefix="case01", ) - h00_path = tmp_path / "case01_tot_w1.h00" - h00_path.write_text("!INTERFILE :=\n") - connector._output_header_paths = {"tot_w1": h00_path} - - class DummySimind: - @staticmethod - def get_metadata(path): - assert path == str(h00_path) - return ("obj_meta", "proj_meta") - - @staticmethod - def get_psfmeta_from_header(path): - assert path == str(h00_path) - return "psf_meta" - - class DummyPSFTransform: - def __init__(self, psf_meta): - self.psf_meta = psf_meta - - class DummySystemMatrix: - def __init__( - self, obj2obj_transforms, proj2proj_transforms, object_meta, proj_meta - ): - self.obj2obj_transforms = obj2obj_transforms - self.proj2proj_transforms = proj2proj_transforms - self.object_meta = object_meta - self.proj_meta = proj_meta - - monkeypatch.setattr(pytomo_mod, "pytomo_simind", DummySimind) - monkeypatch.setattr(pytomo_mod, "SPECTPSFTransform", DummyPSFTransform) - monkeypatch.setattr(pytomo_mod, "SPECTSystemMatrix", DummySystemMatrix) - - object_meta, proj_meta = connector.get_pytomography_metadata("tot_w1") - assert object_meta == "obj_meta" - assert proj_meta == "proj_meta" - - system_matrix = connector.build_system_matrix("tot_w1", use_psf=True) - assert isinstance(system_matrix, DummySystemMatrix) - assert system_matrix.object_meta == "obj_meta" - assert system_matrix.proj_meta == "proj_meta" - assert system_matrix.proj2proj_transforms == [] - assert len(system_matrix.obj2obj_transforms) == 1 - assert system_matrix.obj2obj_transforms[0].psf_meta == "psf_meta" + assert not hasattr(connector, "build_system_matrix") + assert not hasattr(connector, "get_pytomography_metadata") @pytest.mark.unit -def test_pytomography_adaptor_uses_mu_map_in_pytomography_axes( +def test_pytomography_adaptor_forwards_connector_wiring_and_axis_order( tmp_path: Path, monkeypatch ): connector = PyTomographySimindAdaptor( config_source=get("AnyScan.yaml"), output_dir=tmp_path, output_prefix="case01", + voxel_size_mm=3.5, + scoring_routine=ScoringRoutine.PENETRATE, ) - source = torch.zeros((2, 3, 4), dtype=torch.float32) - mu_map = torch.arange(2 * 3 * 4, dtype=torch.float32).reshape(2, 3, 4) - connector.set_source(source) - connector.set_mu_map(mu_map) + source_xyz = torch.arange(2 * 3 * 4, dtype=torch.float32).reshape(2, 3, 4) + mu_xyz = torch.arange(2 * 3 * 4, dtype=torch.float32).reshape(2, 3, 4) / 10.0 + connector.set_source(source_xyz) + connector.set_mu_map(mu_xyz) + connector.set_energy_windows([75.0, 120.0], [85.0, 140.0], [0, 1]) + + captured: dict[str, object] = {} + + def fake_configure_voxel_phantom( + source, + mu_map, + voxel_size_mm, + scoring_routine, + ): + captured["source"] = np.asarray(source) + captured["mu_map"] = np.asarray(mu_map) + captured["voxel_size_mm"] = voxel_size_mm + captured["scoring_routine"] = scoring_routine + return (tmp_path / "case01_src.smi", tmp_path / "case01_dns.dmi") + + def fake_set_energy_windows(lower_bounds, upper_bounds, scatter_orders): + captured["windows"] = ( + list(lower_bounds), + list(upper_bounds), + list(scatter_orders), + ) + + projection = np.arange(1 * 8 * 5 * 8, dtype=np.float32).reshape(1, 8, 5, 8) + raw_outputs = { + "tot_w1": ProjectionResult( + projection=projection, + header_path=tmp_path / "case01_tot_w1.hs", + data_path=tmp_path / "case01_tot_w1.a00", + metadata={"component": "tot_w1"}, + ), + "sca_w1": ProjectionResult( + projection=projection + 1.0, + header_path=tmp_path / "case01_sca_w1.hs", + data_path=tmp_path / "case01_sca_w1.a00", + metadata={"component": "sca_w1"}, + ), + "pri_w1": ProjectionResult( + projection=projection + 2.0, + header_path=tmp_path / "case01_pri_w1.hs", + data_path=tmp_path / "case01_pri_w1.a00", + metadata={"component": "pri_w1"}, + ), + "air_w1": ProjectionResult( + projection=projection + 3.0, + header_path=tmp_path / "case01_air_w1.hs", + data_path=tmp_path / "case01_air_w1.a00", + metadata={"component": "air_w1"}, + ), + } + + def fake_run(runtime_operator=None): + captured["runtime_operator"] = runtime_operator + return raw_outputs - h00_path = tmp_path / "case01_tot_w1.h00" - h00_path.write_text("!INTERFILE :=\n") - connector._output_header_paths = {"tot_w1": h00_path} - - class DummySimind: - @staticmethod - def get_metadata(path): - assert path == str(h00_path) - return ("obj_meta", "proj_meta") - - class DummyAttenuationTransform: - def __init__(self, attenuation_map): - self.attenuation_map = attenuation_map - - class DummySystemMatrix: - def __init__( - self, obj2obj_transforms, proj2proj_transforms, object_meta, proj_meta - ): - self.obj2obj_transforms = obj2obj_transforms - self.proj2proj_transforms = proj2proj_transforms - self.object_meta = object_meta - self.proj_meta = proj_meta - - monkeypatch.setattr(pytomo_mod, "pytomo_simind", DummySimind) - monkeypatch.setattr(pytomo_mod, "SPECTSystemMatrix", DummySystemMatrix) monkeypatch.setattr( - pytomo_mod, "SPECTAttenuationTransform", DummyAttenuationTransform + connector.python_connector, + "configure_voxel_phantom", + fake_configure_voxel_phantom, ) - monkeypatch.setattr(pytomo_mod, "SPECTPSFTransform", None) - - system_matrix = connector.build_system_matrix( - "tot_w1", use_psf=False, use_attenuation=True + monkeypatch.setattr( + connector.python_connector, + "set_energy_windows", + fake_set_energy_windows, + ) + monkeypatch.setattr( + connector.python_connector, + "run", + fake_run, ) - assert len(system_matrix.obj2obj_transforms) == 1 - attenuation_map = system_matrix.obj2obj_transforms[0].attenuation_map - assert tuple(attenuation_map.shape) == tuple(mu_map.shape) - assert torch.equal(attenuation_map, mu_map.contiguous()) + runtime_operator = RuntimeOperator(switches={"RR": 12345}) + outputs = connector.run(runtime_operator=runtime_operator) + + expected_source_zyx = source_xyz.permute(2, 1, 0).numpy() + expected_mu_zyx = mu_xyz.permute(2, 1, 0).numpy() + assert np.array_equal(captured["source"], expected_source_zyx) + assert np.array_equal(captured["mu_map"], expected_mu_zyx) + assert captured["voxel_size_mm"] == pytest.approx(3.5) + assert captured["scoring_routine"] == ScoringRoutine.PENETRATE + assert captured["windows"] == ([75.0, 120.0], [85.0, 140.0], [0, 1]) + assert captured["runtime_operator"] is runtime_operator + + assert torch.equal(connector.get_total_output(window=1), outputs["tot_w1"]) + assert torch.equal(connector.get_scatter_output(window=1), outputs["sca_w1"]) + assert torch.equal(connector.get_primary_output(window=1), outputs["pri_w1"]) + assert torch.equal(connector.get_air_output(window=1), outputs["air_w1"]) @pytest.mark.unit diff --git a/tests/test_simind_simulator.py b/tests/test_simind_simulator.py deleted file mode 100644 index c504c64..0000000 --- a/tests/test_simind_simulator.py +++ /dev/null @@ -1,63 +0,0 @@ -import tempfile - -import pytest - -from sirf_simind_connection import SimindSimulator, SimulationConfig -from sirf_simind_connection.configs import get -from sirf_simind_connection.core.types import ScoringRoutine -from sirf_simind_connection.utils.stir_utils import ( - create_attenuation_map, - create_simple_phantom, -) - - -# All tests in this file require SIRF since they use SimindSimulator with SIRF objects -pytestmark = pytest.mark.requires_sirf - - -def test_simulator_initialization(): - """Test initialization of SimindSimulator.""" - config = SimulationConfig(get("AnyScan.yaml")) - simulator = SimindSimulator(config, "output_dir") - assert simulator is not None - - -def test_simulator_configuration(): - """Test simulator configuration methods.""" - with tempfile.TemporaryDirectory() as temp_dir: - config = SimulationConfig(get("AnyScan.yaml")) - simulator = SimindSimulator(config, temp_dir) - - # Test configuration access - assert simulator.get_config() is not None - assert simulator.get_scoring_routine() == ScoringRoutine.SCATTWIN - - # Test configuration modification - simulator.add_config_value(1, 150.0) # photon energy - assert simulator.get_config().get_value(1) == 150.0 - - -def test_simulator_with_phantom(): - """Test simulator with phantom and attenuation map.""" - with tempfile.TemporaryDirectory() as temp_dir: - config = SimulationConfig(get("AnyScan.yaml")) - simulator = SimindSimulator(config, temp_dir) - - # Create phantom and attenuation map - phantom = create_simple_phantom() - mu_map = create_attenuation_map(phantom) - - # Set inputs - simulator.set_source(phantom) - simulator.set_mu_map(mu_map) - - # Verify inputs are set - assert simulator.source is not None - assert simulator.mu_map is not None - - # Set energy windows - simulator.set_energy_windows( - lower_bounds=[126], upper_bounds=[154], scatter_orders=[0] - ) - - assert len(simulator.energy_windows) == 1