Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# py-smc
# PySIMIND

Python SIMIND Monte Carlo Connector.

Expand Down Expand Up @@ -41,7 +41,7 @@ install a licensed SIMIND installation.
### Basic Installation

```bash
pip install py-smc
pip install PySIMIND
```

Import path remains:
Expand Down Expand Up @@ -147,6 +147,7 @@ connector.configure_voxel_phantom(
source=source,
mu_map=mu_map,
voxel_size_mm=4.0,
mu_map_type="attenuation", # or "density" / "hu"
)
connector.set_energy_windows([126], [154], [0]) # Tc-99m ± 10%
connector.add_runtime_switch("FI", "tc99m")
Expand All @@ -159,6 +160,13 @@ total = outputs["tot_w1"].projection
print(total.shape)
```

`mu_map_type` controls how `mu_map` is interpreted before writing SIMIND
density input:

- `"attenuation"`: linear attenuation coefficients (cm^-1), converted to density
- `"density"`: density map in g/cm^3, used directly
- `"hu"`: CT HU map, converted with the Schneider model

### Advanced Density Conversion

```python
Expand Down
13 changes: 12 additions & 1 deletion docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,22 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.0.0/>`
Unreleased
----------

Added
~~~~~

- ``mu_map_type`` input selection for SIMIND phantom setup, supporting
attenuation maps, density maps, and HU maps in
``SimindPythonConnector.configure_voxel_phantom()``.

Changed
~~~~~~~

- Updated user-facing branding to ``py-smc`` and added explicit non-affiliation
- Updated user-facing branding to ``PySIMIND`` and added explicit non-affiliation
and external-SIMIND-install disclaimers in README/docs.
- STIR/SIRF/PyTomography adaptors now forward ``mu_map_type`` so input-map
interpretation is configurable across connector and adaptor workflows.
- Usage docs now describe ``mu_map_type`` options and example calls for
connector/adaptor setup.

[0.5.0] - 2026-03-05
--------------------
Expand Down
6 changes: 3 additions & 3 deletions docs/conf.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Sphinx configuration file for py-smc documentation."""
"""Sphinx configuration file for PySIMIND documentation."""

import os
import sys
Expand All @@ -10,11 +10,11 @@
sys.path.insert(0, os.path.abspath(".."))

# Project information
project = "py-smc"
project = "PySIMIND"
copyright = f"{datetime.now().year}, Sam Porter, Efstathios Varzakis"
author = "Sam Porter, Efstathios Varzakis"
release = "0.5.0"
for dist_name in ("py-smc", "sirf-simind-connection"):
for dist_name in ("PySIMIND", "py-smc", "sirf-simind-connection"):
try:
release = importlib_metadata.version(dist_name)
break
Expand Down
4 changes: 2 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
py-smc Documentation
====================
PySIMIND Documentation
======================

.. toctree::
:maxdepth: 2
Expand Down
2 changes: 1 addition & 1 deletion docs/intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Introduction
============

py-smc is a Python toolkit for SIMIND SPECT workflows.
PySIMIND is a Python toolkit for SIMIND SPECT workflows.

Disclaimer
----------
Expand Down
2 changes: 1 addition & 1 deletion docs/testing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Testing
=======

This document explains the testing strategy for py-smc, which handles the
This document explains the testing strategy for PySIMIND, 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.
Expand Down
23 changes: 23 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,26 @@ inputs/outputs without any reconstruction-package dependency.
total = outputs["tot_w1"].projection
print(total.shape)

Map Input Types
---------------

``configure_voxel_phantom()`` supports three ``mu_map`` input conventions via
``mu_map_type``:

- ``"attenuation"``: linear attenuation coefficients (cm^-1), converted to
density before SIMIND input writing.
- ``"density"``: density map in g/cm^3, passed through directly.
- ``"hu"``: CT HU map, converted to density with the Schneider model.

.. code-block:: python

connector.configure_voxel_phantom(
source=source,
mu_map=mu_map,
voxel_size_mm=4.0,
mu_map_type="attenuation", # or "density" / "hu"
)

Adaptor Workflows
-----------------

Expand All @@ -60,6 +80,7 @@ STIR adaptor
config_source=get("Example.yaml"),
output_dir="output/stir_adaptor",
output_prefix="stir_case01",
mu_map_type="attenuation", # or "density" / "hu"
)
adaptor.set_source(stir_source)
adaptor.set_mu_map(stir_mu_map)
Expand All @@ -83,6 +104,7 @@ SIRF adaptor
config_source=get("Example.yaml"),
output_dir="output/sirf_adaptor",
output_prefix="sirf_case01",
mu_map_type="attenuation", # or "density" / "hu"
)
adaptor.set_source(sirf_source)
adaptor.set_mu_map(sirf_mu_map)
Expand Down Expand Up @@ -113,6 +135,7 @@ Build the system matrix directly with PyTomography APIs.
config_source=get("Example.yaml"),
output_dir="output/pytomo_adaptor",
output_prefix="pytomo_case01",
mu_map_type="attenuation", # or "density" / "hu"
)
adaptor.set_source(source_tensor_xyz)
adaptor.set_mu_map(mu_tensor_xyz)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "py-smc"
name = "PySIMIND"
version = "0.5.0"
description = "Python SIMIND Monte Carlo connector with STIR/SIRF/PyTomography adaptors"
readme = "README.md"
Expand Down
4 changes: 2 additions & 2 deletions sirf_simind_connection/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""
py-smc connector/adaptor API.
PySIMIND connector/adaptor API.
"""

import importlib
from importlib import metadata as _meta
from typing import Any


for _dist_name in ("py-smc", "sirf-simind-connection", __name__):
for _dist_name in ("PySIMIND", "py-smc", "sirf-simind-connection", __name__):
try: # installed (pip/poetry)
__version__ = _meta.version(_dist_name)
break
Expand Down
46 changes: 42 additions & 4 deletions sirf_simind_connection/connectors/python_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional, Union
from typing import Any, Dict, Literal, Optional, Union

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.attenuation import (
attenuation_to_density,
hu_to_density_schneider,
)
Comment on lines +18 to +21
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HU conversion relies on hu_to_density_schneider(), which reads Schneider2000.json from package resources. Currently pyproject.toml only includes sirf_simind_connection/data/*.atn as package data, so installed distributions may miss Schneider2000.json and HU workflows will fail at runtime. Include Schneider2000.json (or data/*.json) in package data / manifest so the resource is shipped with the wheel/sdist.

Copilot uses AI. Check for mistakes.
from sirf_simind_connection.converters.simind_to_stir import SimindToStirConverter
from sirf_simind_connection.core.config import RuntimeSwitches, SimulationConfig
from sirf_simind_connection.core.executor import SimindExecutor
Expand All @@ -31,6 +34,7 @@

ConfigSource = Union[str, os.PathLike[str], SimulationConfig]
PathLike = Union[str, os.PathLike[str]]
MuMapType = Literal["attenuation", "density", "hu"]


@dataclass(frozen=True)
Expand Down Expand Up @@ -108,15 +112,31 @@ def configure_voxel_phantom(
mu_map: np.ndarray,
voxel_size_mm: float = 4.0,
scoring_routine: Union[ScoringRoutine, int] = ScoringRoutine.SCATTWIN,
mu_map_type: MuMapType = "attenuation",
) -> tuple[Path, Path]:
"""
Configure voxel geometry and write source/density input files.

Args:
source: Source activity array in SIMIND image axes (z, y, x).
mu_map: Input volume interpreted according to ``mu_map_type``.
voxel_size_mm: Isotropic voxel size in mm.
scoring_routine: SIMIND scoring routine identifier.
mu_map_type: Interpretation of ``mu_map`` values:
- ``"attenuation"``: linear attenuation (cm^-1), converted to density.
- ``"density"``: density (g/cm^3), written directly.
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring for mu_map_type="density" says the density map is "written directly", but the implementation still scales by * 1000.0 and rounds/clips to uint16 (mg/cm^3 in the SIMIND file). Please clarify in the docstring (and/or user docs) that the input is expected in g/cm^3 but the written file uses mg/cm^3 scaling, to avoid users passing already-mg values and getting a 1000× error.

Suggested change
- ``"density"``: density (g/cm^3), written directly.
- ``"density"``: density (g/cm^3); values are expected in g/cm^3 and
are internally scaled to mg/cm^3 and quantized to ``uint16`` when
writing the SIMIND density file.

Copilot uses AI. Check for mistakes.
- ``"hu"``: CT Hounsfield Units, converted via Schneider model.

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)
normalized_map_type = str(mu_map_type).strip().lower()
if normalized_map_type not in {"attenuation", "density", "hu"}:
raise ValueError(
"mu_map_type must be one of: 'attenuation', 'density', 'hu'"
)
Comment on lines +135 to +139
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalized_map_type is computed as a plain str and then passed into _convert_mu_input_to_density(), which is typed to accept MuMapType (a Literal[...]). This is a type-hint mismatch that will trip static type checkers and makes the intent less clear. After validating membership, cast normalized_map_type to MuMapType (or change _convert_mu_input_to_density to accept str) so the types align.

Copilot uses AI. Check for mistakes.

if source_array.ndim != 3 or mu_map_array.ndim != 3:
raise ValueError("source and mu_map must both be 3D arrays")
Expand Down Expand Up @@ -176,8 +196,11 @@ def configure_voxel_phantom(
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
density = self._convert_mu_input_to_density(
mu_map_array=mu_map_array,
mu_map_type=normalized_map_type,
photon_energy=float(cfg.get_value("photon_energy")),
)
else:
density = np.zeros_like(mu_map_array)

Expand All @@ -191,6 +214,21 @@ def configure_voxel_phantom(

return source_path, density_path

@staticmethod
def _convert_mu_input_to_density(
mu_map_array: np.ndarray,
mu_map_type: MuMapType,
photon_energy: float,
) -> np.ndarray:
"""Convert attenuation/HU/density map input to density in mg/cm^3."""
if mu_map_type == "attenuation":
density_g_cm3 = attenuation_to_density(mu_map_array, photon_energy)
elif mu_map_type == "density":
density_g_cm3 = mu_map_array
else: # mu_map_type == "hu"
density_g_cm3 = hu_to_density_schneider(mu_map_array)
return density_g_cm3 * 1000.0

def set_energy_windows(
self,
lower_bounds: Union[float, list[float]],
Expand Down
4 changes: 4 additions & 0 deletions sirf_simind_connection/connectors/pytomography_adaptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from sirf_simind_connection.connectors.base import BaseConnector
from sirf_simind_connection.connectors.python_connector import (
ConfigSource,
MuMapType,
RuntimeOperator,
SimindPythonConnector,
)
Expand Down Expand Up @@ -56,6 +57,7 @@ def __init__(
voxel_size_mm: float = 4.0,
quantization_scale: float = 1.0,
scoring_routine: Union[ScoringRoutine, int] = ScoringRoutine.SCATTWIN,
mu_map_type: MuMapType = "attenuation",
) -> None:
if torch is None:
raise ImportError(
Expand All @@ -80,6 +82,7 @@ def __init__(
if isinstance(scoring_routine, int)
else scoring_routine
)
self._mu_map_type: MuMapType = mu_map_type

self._source: Optional[torch.Tensor] = None
self._mu_map: Optional[torch.Tensor] = None
Expand Down Expand Up @@ -146,6 +149,7 @@ def run(
mu_map=mu_map_zyx,
voxel_size_mm=self.voxel_size_mm,
scoring_routine=self._scoring_routine,
mu_map_type=self._mu_map_type,
)
self.python_connector.set_energy_windows(*self._energy_windows)

Expand Down
4 changes: 4 additions & 0 deletions sirf_simind_connection/connectors/sirf_adaptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from sirf_simind_connection.connectors.base import BaseConnector
from sirf_simind_connection.connectors.python_connector import (
ConfigSource,
MuMapType,
RuntimeOperator,
SimindPythonConnector,
)
Expand All @@ -35,6 +36,7 @@ def __init__(
photon_multiplier: int = 1,
quantization_scale: float = 1.0,
scoring_routine: ScoringRoutine | int = ScoringRoutine.SCATTWIN,
mu_map_type: MuMapType = "attenuation",
) -> None:
if sirf is None:
raise ImportError("SirfSimindAdaptor requires the SIRF Python package.")
Expand All @@ -53,6 +55,7 @@ def __init__(
self._source: Any = None
self._mu_map: Any = None
self._outputs: Optional[dict[str, Any]] = None
self._mu_map_type: MuMapType = mu_map_type

self.add_runtime_switch("NN", photon_multiplier)

Expand Down Expand Up @@ -92,6 +95,7 @@ def run(self, runtime_operator: Optional[RuntimeOperator] = None) -> dict[str, A
mu_map=mu_arr,
voxel_size_mm=voxel_size_mm,
scoring_routine=self._scoring_routine,
mu_map_type=self._mu_map_type,
)
raw_outputs = self.python_connector.run(runtime_operator=runtime_operator)
self._outputs = {
Expand Down
4 changes: 4 additions & 0 deletions sirf_simind_connection/connectors/stir_adaptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from sirf_simind_connection.connectors.base import BaseConnector
from sirf_simind_connection.connectors.python_connector import (
ConfigSource,
MuMapType,
RuntimeOperator,
SimindPythonConnector,
)
Expand All @@ -35,6 +36,7 @@ def __init__(
photon_multiplier: int = 1,
quantization_scale: float = 1.0,
scoring_routine: ScoringRoutine | int = ScoringRoutine.SCATTWIN,
mu_map_type: MuMapType = "attenuation",
) -> None:
if stir is None:
raise ImportError("StirSimindAdaptor requires the STIR Python package.")
Expand All @@ -53,6 +55,7 @@ def __init__(
self._source: Any = None
self._mu_map: Any = None
self._outputs: Optional[dict[str, Any]] = None
self._mu_map_type: MuMapType = mu_map_type

self.add_runtime_switch("NN", photon_multiplier)

Expand Down Expand Up @@ -92,6 +95,7 @@ def run(self, runtime_operator: Optional[RuntimeOperator] = None) -> dict[str, A
mu_map=mu_arr,
voxel_size_mm=voxel_size_mm,
scoring_routine=self._scoring_routine,
mu_map_type=self._mu_map_type,
)
raw_outputs = self.python_connector.run(runtime_operator=runtime_operator)
self._outputs = {
Expand Down
Loading
Loading