simind-python-connector 1.1.0__tar.gz → 1.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. {simind_python_connector-1.1.0/simind_python_connector.egg-info → simind_python_connector-1.1.1}/PKG-INFO +1 -1
  2. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/backends.rst +1 -1
  3. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/changelog.rst +56 -0
  4. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/conf.py +1 -1
  5. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/examples/07C_pytomography_adaptor_osem.py +7 -15
  6. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1/docs}/examples/_python_connector_helpers.py +3 -2
  7. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/geometry.rst +6 -7
  8. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/examples/07C_pytomography_adaptor_osem.py +7 -15
  9. {simind_python_connector-1.1.0/docs → simind_python_connector-1.1.1}/examples/_python_connector_helpers.py +3 -2
  10. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/pyproject.toml +2 -2
  11. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/__init__.py +1 -1
  12. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/backends/sirf_backend.py +3 -3
  13. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/builders/acquisition_builder.py +51 -24
  14. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/builders/image_builder.py +3 -0
  15. simind_python_connector-1.1.1/simind_python_connector/connectors/_spacing.py +83 -0
  16. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/connectors/python_connector.py +30 -24
  17. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/connectors/pytomography_adaptor.py +17 -24
  18. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/connectors/sirf_adaptor.py +12 -2
  19. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/connectors/stir_adaptor.py +12 -2
  20. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/converters/attenuation.py +12 -14
  21. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/converters/simind_to_stir.py +26 -17
  22. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/core/config.py +41 -0
  23. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/core/executor.py +13 -2
  24. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/utils/interfile_parser.py +11 -2
  25. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/utils/stir_utils.py +4 -4
  26. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1/simind_python_connector.egg-info}/PKG-INFO +1 -1
  27. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_acquisition_builder_unit.py +35 -6
  28. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_attenuation_utils.py +14 -4
  29. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_backends.py +31 -0
  30. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_dicom_builder.py +16 -3
  31. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_example_config_profile.py +26 -0
  32. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_executor.py +33 -4
  33. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_image_builder_unit.py +30 -0
  34. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_interfile_parser.py +19 -0
  35. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_native_adaptors.py +125 -93
  36. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_python_connector.py +69 -9
  37. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_pytomography_adaptor.py +123 -8
  38. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_schneider_density.py +5 -0
  39. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_simind_to_stir_converter.py +41 -0
  40. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_simulation_config.py +13 -0
  41. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_stir_utils_backend_choice_unit.py +1 -1
  42. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_utils.py +3 -8
  43. simind_python_connector-1.1.0/simind_python_connector/connectors/_spacing.py +0 -75
  44. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/LICENSE +0 -0
  45. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/MANIFEST.in +0 -0
  46. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/README.md +0 -0
  47. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/api.rst +0 -0
  48. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/connector_modernization_plan.rst +0 -0
  49. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/contributing.rst +0 -0
  50. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/examples/01_basic_simulation.py +0 -0
  51. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/examples/02_runtime_switch_comparison.py +0 -0
  52. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/examples/03_multi_window.py +0 -0
  53. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/examples/04_custom_config.py +0 -0
  54. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/examples/05_scattwin_vs_penetrate_comparison.py +0 -0
  55. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/examples/06_schneider_density_conversion.py +0 -0
  56. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/examples/07A_stir_adaptor_osem.py +0 -0
  57. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/examples/07B_sirf_adaptor_osem.py +0 -0
  58. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/examples/08A_stir_adaptor_from_dicom.py +0 -0
  59. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/examples/08B_sirf_adaptor_from_dicom.py +0 -0
  60. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/examples/08C_pytomography_adaptor_from_dicom.py +0 -0
  61. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/examples.rst +0 -0
  62. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/index.rst +0 -0
  63. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/installation.rst +0 -0
  64. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/intro.rst +0 -0
  65. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/testing.rst +0 -0
  66. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/docs/usage.rst +0 -0
  67. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/examples/01_basic_simulation.py +0 -0
  68. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/examples/02_runtime_switch_comparison.py +0 -0
  69. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/examples/03_multi_window.py +0 -0
  70. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/examples/04_custom_config.py +0 -0
  71. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/examples/05_scattwin_vs_penetrate_comparison.py +0 -0
  72. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/examples/06_schneider_density_conversion.py +0 -0
  73. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/examples/07A_stir_adaptor_osem.py +0 -0
  74. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/examples/07B_sirf_adaptor_osem.py +0 -0
  75. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/examples/08A_stir_adaptor_from_dicom.py +0 -0
  76. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/examples/08B_sirf_adaptor_from_dicom.py +0 -0
  77. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/examples/08C_pytomography_adaptor_from_dicom.py +0 -0
  78. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/requirements-dev.txt +0 -0
  79. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/requirements.txt +0 -0
  80. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/setup.cfg +0 -0
  81. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/backends/__init__.py +0 -0
  82. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/backends/base.py +0 -0
  83. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/backends/stir_backend.py +0 -0
  84. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/builders/__init__.py +0 -0
  85. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/configs/AnyScan.yaml +0 -0
  86. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/configs/Discovery670.yaml +0 -0
  87. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/configs/Example.yaml +0 -0
  88. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/configs/MLD001_SCAN0.yaml +0 -0
  89. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/configs/__init__.py +0 -0
  90. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/configs/input.smc +0 -0
  91. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/connectors/__init__.py +0 -0
  92. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/connectors/base.py +0 -0
  93. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/converters/__init__.py +0 -0
  94. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/converters/dicom_to_stir.py +0 -0
  95. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/core/__init__.py +0 -0
  96. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/core/types.py +0 -0
  97. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/data/Schneider2000.json +0 -0
  98. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/data/__init__.py +0 -0
  99. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/data/bone.atn +0 -0
  100. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/data/h2o.atn +0 -0
  101. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/utils/__init__.py +0 -0
  102. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/utils/backend_access.py +0 -0
  103. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/utils/import_helpers.py +0 -0
  104. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/utils/interfile_numpy.py +0 -0
  105. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/utils/io_utils.py +0 -0
  106. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/utils/simind_utils.py +0 -0
  107. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector/utils/sirf_stir_utils.py +0 -0
  108. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector.egg-info/SOURCES.txt +0 -0
  109. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector.egg-info/dependency_links.txt +0 -0
  110. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector.egg-info/requires.txt +0 -0
  111. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/simind_python_connector.egg-info/top_level.txt +0 -0
  112. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/__init__.py +0 -0
  113. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/conftest.py +0 -0
  114. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_arithmetic_operations.py +0 -0
  115. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_connector_backend_separation.py +0 -0
  116. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_connectors_base.py +0 -0
  117. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_container_library_isolation.py +0 -0
  118. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_container_scripts.py +0 -0
  119. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_examples_backend_separation.py +0 -0
  120. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_geometry_isolation_forward_projection.py +0 -0
  121. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_integration.py +0 -0
  122. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_interfile_numpy.py +0 -0
  123. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_legacy_surface_policy.py +0 -0
  124. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_marker_policy.py +0 -0
  125. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_non_square_slices_simind.py +0 -0
  126. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_osem_geometry_diagnostics.py +0 -0
  127. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_package_identity.py +0 -0
  128. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_packaging_resources.py +0 -0
  129. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_sirf_stir_utils.py +0 -0
  130. {simind_python_connector-1.1.0 → simind_python_connector-1.1.1}/tests/test_utils_small.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simind-python-connector
3
- Version: 1.1.0
3
+ Version: 1.1.1
4
4
  Summary: Python SIMIND Monte Carlo connector with STIR/SIRF/PyTomography adaptors
5
5
  Author-email: Sam Porter <sam.porter.18@ucl.ac.uk>, Rebecca Gillen <rebecca.gillen.18@ucl.ac.uk>, "Efstathios (Stahis) Varzakis" <efstathios.varzakis.22@ucl.ac.uk>
6
6
  Maintainer-email: Sam Porter <sam.porter.18@ucl.ac.uk>
@@ -16,7 +16,7 @@ Dependency Matrix
16
16
  * - ``SimindPythonConnector``
17
17
  - SIMIND executable on ``PATH`` (no SIRF/STIR/PyTomography requirement)
18
18
  * - ``StirSimindAdaptor``
19
- - STIR Python (``stir``)
19
+ - STIR Python (``stir`` and ``stirextra``)
20
20
  * - ``SirfSimindAdaptor``
21
21
  - SIRF (``sirf.STIR``)
22
22
  * - ``PyTomographySimindAdaptor``
@@ -7,6 +7,62 @@ All notable changes to this project are documented in this file.
7
7
 
8
8
  The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.0.0/>`_, and this project adheres to `Semantic Versioning <https://semver.org/spec/v2.0.0.html>`_.
9
9
 
10
+ 1.1.1 - 2026-10-11
11
+ ------------------
12
+
13
+ Fixed
14
+ ~~~~~
15
+
16
+ - A negative Index 1 (energies from the isotope file) turned the mu map into
17
+ near-vacuum; it is now converted at ``abs(Index 1)``.
18
+ - Acquisition arrays use ``(tof, axial, view, bin)``, and DICOM matrix sizes and
19
+ pixel spacing are no longer swapped, so non-square projections build
20
+ correctly. ``create_stir_acqdata`` takes ``(bins, axial)`` and uses the same
21
+ layout.
22
+ - The PyTomography adaptor reverses axial slices to match PyTomography's SIMIND
23
+ reader; reconstructions were flipped along z.
24
+ - The STIR/SIRF adaptors raise a ``ValueError`` for non-cubic voxels instead of
25
+ using the z spacing for every axis.
26
+ - The adaptors default ``scoring_routine`` to None, which keeps the
27
+ configuration's Index 84; they used to overwrite it with SCATTWIN.
28
+ - The geometry docs give the projection axis orders correctly.
29
+ - PyTomography PENETRATE and fallback outputs use the reader's
30
+ ``(view, bin, axial)`` order, and PyTomography scatter orders are validated
31
+ like the core's.
32
+ - The STIR/SIRF adaptors reject a mu map whose voxel size differs from the
33
+ source's.
34
+ - The legacy SIRF attenuation loader reverses the voxel sizes with the
35
+ dimensions.
36
+ - The SIRF wrapper's ``maximum()`` and ``sapyb()`` update the wrapped object.
37
+ - Piecewise Schneider conversion gives HU outside the table the nearest
38
+ segment's density, so metal above 4001 HU no longer becomes air.
39
+ - MPI runs use ``$SIMIND_MPI_BIN``, else ``simind_mpi`` next to the serial
40
+ binary, with ``/MP`` and without a stray ``-p``.
41
+ - An output header that cannot be read raises instead of being skipped.
42
+ - YAML export keeps unnamed parameters and flags.
43
+ - New header keys are written before ``!END OF INTERFILE``, and ``set()``
44
+ changes the last duplicate key, the one readers use.
45
+ - A header converted into another directory points at its data by absolute
46
+ path, and an orbit file named like an ignored key keeps its radii.
47
+ - DICOM frames are split by ``EnergyWindowVector`` when present; each output
48
+ declares one window and its share of the duration.
49
+ - ``run()`` sets Flags 5 and 14, which SIMIND needs to write readable headers,
50
+ so config-defined phantom runs work.
51
+ - Example 07C no longer drops attenuation and PSF modelling silently, and the
52
+ examples accept ``SIMIND_BIN``.
53
+ - Importing the converter no longer configures root logging.
54
+ - setuptools 77.0.3 or newer is required to build.
55
+
56
+ Changed
57
+ ~~~~~~~
58
+
59
+ - ``get_attenuation_coefficient`` takes keV, as documented.
60
+ - ``NN`` must be an integer >= 1, switches reject None and bool values,
61
+ ``quantization_scale`` must be in (0, 1], and ``output_prefix`` must match
62
+ ``[a-z0-9][a-z0-9_-]*`` (SIMIND lowercases file names).
63
+ - Publishing waits for the tests and checks the tag against the version; tests
64
+ also run on ``release/**`` branches.
65
+
10
66
  1.1.0 - 2026-10-11
11
67
  ------------------
12
68
 
@@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath(".."))
13
13
  project = "simind-python-connector"
14
14
  copyright = f"{datetime.now().year}, Sam Porter, Efstathios Varzakis"
15
15
  author = "Sam Porter, Efstathios Varzakis"
16
- release = "1.1.0"
16
+ release = "1.1.1"
17
17
  for dist_name in ("simind-python-connector",):
18
18
  try:
19
19
  release = importlib_metadata.version(dist_name)
@@ -90,21 +90,13 @@ def _build_system_matrix(
90
90
  ) -> SPECTSystemMatrix:
91
91
  object_meta, proj_meta = pytomo_simind.get_metadata(str(projection_header))
92
92
 
93
- obj2obj_transforms = []
94
- try:
95
- obj2obj_transforms.append(
96
- SPECTAttenuationTransform(
97
- attenuation_map=attenuation_map_xyz.to(dtype=torch.float32).contiguous()
98
- )
99
- )
100
- except Exception:
101
- pass
102
-
103
- try:
104
- psf_meta = pytomo_simind.get_psfmeta_from_header(str(projection_header))
105
- obj2obj_transforms.append(SPECTPSFTransform(psf_meta=psf_meta))
106
- except Exception:
107
- pass
93
+ psf_meta = pytomo_simind.get_psfmeta_from_header(str(projection_header))
94
+ obj2obj_transforms = [
95
+ SPECTAttenuationTransform(
96
+ attenuation_map=attenuation_map_xyz.to(dtype=torch.float32).contiguous()
97
+ ),
98
+ SPECTPSFTransform(psf_meta=psf_meta),
99
+ ]
108
100
 
109
101
  return SPECTSystemMatrix(
110
102
  obj2obj_transforms=obj2obj_transforms,
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import shutil
4
5
 
5
6
  import numpy as np
@@ -8,9 +9,9 @@ from simind_python_connector import SimindPythonConnector
8
9
 
9
10
 
10
11
  def require_simind() -> None:
11
- if shutil.which("simind") is None:
12
+ if shutil.which(os.environ.get("SIMIND_BIN") or "simind") is None:
12
13
  raise RuntimeError(
13
- "SIMIND executable not found in PATH. "
14
+ "SIMIND executable not found ($SIMIND_BIN or simind on PATH). "
14
15
  "Install SIMIND before running this example."
15
16
  )
16
17
 
@@ -9,9 +9,9 @@ At-a-Glance Axis Conventions
9
9
 
10
10
  - STIR/SIRF image arrays are handled as ``(z, y, x)``.
11
11
  - STIR/SIRF projection arrays are often 4D with a singleton TOF axis:
12
- ``(tof, bin, view, axial)``. For these, **view 0** is ``arr[0, :, 0, :]``.
12
+ ``(tof, axial, view, bin)``. For these, **view 0** is ``arr[0, :, 0, :]``.
13
13
  - Raw Interfile projection loading in ``interfile_numpy`` is exposed as
14
- ``(views, bins, axial)`` when no singleton TOF axis is present.
14
+ ``(view, axial, bin)`` when no singleton TOF axis is present.
15
15
  - ``PyTomographySimindAdaptor`` public object-space tensors are ``(x, y, z)``.
16
16
  - PyTomography object space is ``(x, y, z)`` (``Lx, Ly, Lz``).
17
17
  - PyTomography SIMIND projections from ``pytomography.io.SPECT.simind`` are
@@ -35,11 +35,10 @@ Units
35
35
  - STIR/SIRF image geometry is typically expressed in **mm**.
36
36
  - Connectors handle conversion internally via voxel-size settings (for example,
37
37
  runtime switch ``PX`` is set in cm for SIMIND).
38
- - STIR/SIRF adaptor voxel size extraction expects z-spacing in mm. Images
39
- report ``voxel_sizes()`` as ``(z, y, x)``, so the **first** element is the z
40
- spacing. Raw ``get_grid_spacing()`` sequences follow the STIR coordinate
41
- layout ``(unused, z, y, x)`` and use index 1; coordinate objects with a
42
- ``z`` accessor use that accessor directly.
38
+ - STIR/SIRF adaptors read the voxel spacing in mm and need cubic voxels; other
39
+ images raise a ``ValueError``. Images report ``voxel_sizes()`` as
40
+ ``(z, y, x)``, and raw ``get_grid_spacing()`` sequences follow the STIR
41
+ coordinate layout ``(unused, z, y, x)``.
43
42
 
44
43
  Projection Size
45
44
  ---------------
@@ -90,21 +90,13 @@ def _build_system_matrix(
90
90
  ) -> SPECTSystemMatrix:
91
91
  object_meta, proj_meta = pytomo_simind.get_metadata(str(projection_header))
92
92
 
93
- obj2obj_transforms = []
94
- try:
95
- obj2obj_transforms.append(
96
- SPECTAttenuationTransform(
97
- attenuation_map=attenuation_map_xyz.to(dtype=torch.float32).contiguous()
98
- )
99
- )
100
- except Exception:
101
- pass
102
-
103
- try:
104
- psf_meta = pytomo_simind.get_psfmeta_from_header(str(projection_header))
105
- obj2obj_transforms.append(SPECTPSFTransform(psf_meta=psf_meta))
106
- except Exception:
107
- pass
93
+ psf_meta = pytomo_simind.get_psfmeta_from_header(str(projection_header))
94
+ obj2obj_transforms = [
95
+ SPECTAttenuationTransform(
96
+ attenuation_map=attenuation_map_xyz.to(dtype=torch.float32).contiguous()
97
+ ),
98
+ SPECTPSFTransform(psf_meta=psf_meta),
99
+ ]
108
100
 
109
101
  return SPECTSystemMatrix(
110
102
  obj2obj_transforms=obj2obj_transforms,
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import shutil
4
5
 
5
6
  import numpy as np
@@ -8,9 +9,9 @@ from simind_python_connector import SimindPythonConnector
8
9
 
9
10
 
10
11
  def require_simind() -> None:
11
- if shutil.which("simind") is None:
12
+ if shutil.which(os.environ.get("SIMIND_BIN") or "simind") is None:
12
13
  raise RuntimeError(
13
- "SIMIND executable not found in PATH. "
14
+ "SIMIND executable not found ($SIMIND_BIN or simind on PATH). "
14
15
  "Install SIMIND before running this example."
15
16
  )
16
17
 
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["setuptools>=61.0", "wheel"]
2
+ requires = ["setuptools>=77.0.3", "wheel"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simind-python-connector"
7
- version = "1.1.0"
7
+ version = "1.1.1"
8
8
  description = "Python SIMIND Monte Carlo connector with STIR/SIRF/PyTomography adaptors"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -14,7 +14,7 @@ for _dist_name in ("simind-python-connector", __name__):
14
14
  except _meta.PackageNotFoundError:
15
15
  continue
16
16
  else: # editable / source checkout
17
- __version__ = "1.1.0"
17
+ __version__ = "1.1.1"
18
18
 
19
19
 
20
20
  def __getattr__(name: str) -> Any:
@@ -88,7 +88,7 @@ class SirfImageData(ImageDataInterface):
88
88
 
89
89
  def maximum(self, value: float) -> None:
90
90
  """Apply element-wise maximum."""
91
- self._obj.maximum(value)
91
+ self._obj.maximum(value, out=self._obj)
92
92
 
93
93
  @property
94
94
  def native_object(self):
@@ -150,7 +150,7 @@ class SirfImageData(ImageDataInterface):
150
150
  SirfImageData: self after modification (for chaining)
151
151
  """
152
152
  y_obj = y._obj if isinstance(y, SirfImageData) else y
153
- self._obj.sapyb(a, y_obj, b)
153
+ self._obj.sapyb(a, y_obj, b, out=self._obj)
154
154
  return self
155
155
 
156
156
 
@@ -305,5 +305,5 @@ class SirfAcquisitionData(AcquisitionDataInterface):
305
305
  SirfAcquisitionData: self after modification (for chaining)
306
306
  """
307
307
  y_obj = y._obj if isinstance(y, SirfAcquisitionData) else y
308
- self._obj.sapyb(a, y_obj, b)
308
+ self._obj.sapyb(a, y_obj, b, out=self._obj)
309
309
  return self
@@ -92,22 +92,22 @@ class STIRSPECTAcquisitionDataBuilder:
92
92
  num_projections = int(self.header.get("!number of projections", 1))
93
93
  if self.pixel_array is None:
94
94
  self.pixel_array = np.zeros(
95
- (1, matrix_size_1, num_projections, matrix_size_2), dtype=np.float32
95
+ (1, matrix_size_2, num_projections, matrix_size_1), dtype=np.float32
96
96
  )
97
97
  else:
98
98
  self.pixel_array = np.array(self.pixel_array, dtype=np.float32)
99
99
 
100
100
  expected_shape = (
101
101
  1,
102
- matrix_size_1,
103
- num_projections,
104
102
  matrix_size_2,
103
+ num_projections,
104
+ matrix_size_1,
105
105
  )
106
106
  if self.pixel_array.shape != expected_shape:
107
107
  raise ValueError(
108
108
  f"pixel array shape {self.pixel_array.shape} does not match "
109
109
  f"header dimensions {expected_shape} "
110
- "(tof, bin, view, axial)"
110
+ "(tof, axial, view, bin)"
111
111
  )
112
112
 
113
113
  def _write(base_path: Path, cleanup: bool) -> AcquisitionData:
@@ -115,6 +115,8 @@ class STIRSPECTAcquisitionDataBuilder:
115
115
  raw_file_path = base_path.with_suffix(".s")
116
116
 
117
117
  self.header["name of data file"] = raw_file_path.name
118
+ # Keys added since an earlier build must come before the terminator.
119
+ self.header.pop("!END OF INTERFILE", None)
118
120
  self.header["!END OF INTERFILE"] = ""
119
121
 
120
122
  with open(header_path, "w") as f:
@@ -221,35 +223,54 @@ class STIRSPECTAcquisitionDataBuilder:
221
223
  f"pixel array shape {self.pixel_array.shape} does not match "
222
224
  f"declared {total_projections} projections"
223
225
  )
224
- if total_projections % num_windows != 0:
225
- raise ValueError(
226
- f"Number of projections ({total_projections}) must be divisible "
227
- f"by the number of energy windows ({num_windows})"
228
- )
229
- projections_per_window = total_projections // num_windows
230
226
 
231
- # number of projections needs dividing by number of energy windows
232
- self.header["!number of projections"] = str(projections_per_window)
227
+ window_vector = getattr(self, "energy_window_vector", None)
228
+ if window_vector is not None:
229
+ window_vector = np.asarray(window_vector)
230
+ if len(window_vector) != total_projections:
231
+ raise ValueError(
232
+ f"EnergyWindowVector has {len(window_vector)} entries for "
233
+ f"{total_projections} frames"
234
+ )
235
+ frames_per_window = [
236
+ np.flatnonzero(window_vector == idx + 1) for idx in range(num_windows)
237
+ ]
238
+ else:
239
+ if total_projections % num_windows != 0:
240
+ raise ValueError(
241
+ f"Number of projections ({total_projections}) must be divisible "
242
+ f"by the number of energy windows ({num_windows})"
243
+ )
244
+ # Without a window vector, assume one contiguous block per window.
245
+ frames_per_window = np.array_split(
246
+ np.arange(total_projections), num_windows
247
+ )
233
248
 
234
- # split pixel_array into energy windows along 3rd axis
235
249
  original_pixel_array = self.pixel_array
250
+ original_header = dict(self.header)
251
+ duration = original_header.get("!image duration (sec)[1]")
236
252
  try:
237
- pixel_array_list = np.array_split(original_pixel_array, num_windows, axis=2)
238
-
239
253
  acqdata_list = []
240
254
  for idx, ew in enumerate(self.energy_windows):
241
- # Update header for this energy window.
255
+ frames = frames_per_window[idx]
256
+ self.header["!number of projections"] = str(len(frames))
257
+ self.header["!number of energy windows"] = "1"
242
258
  self.header["energy window lower level[1]"] = ew["lower"]
243
259
  self.header["energy window upper level[1]"] = ew["upper"]
244
- suffix = f"_ew{idx + 1}"
245
- output_path = output_path_base + suffix
246
- self.pixel_array = pixel_array_list[idx]
260
+ if duration is not None:
261
+ # Windows are acquired simultaneously, so each output
262
+ # covers only its own frames.
263
+ self.header["!image duration (sec)[1]"] = (
264
+ f"{float(duration) * len(frames) / total_projections:g}"
265
+ )
266
+ output_path = output_path_base + f"_ew{idx + 1}"
267
+ self.pixel_array = original_pixel_array[:, :, frames, :]
247
268
  acqdata = self.build(output_path=output_path)
248
269
  acqdata_list.append(acqdata)
249
270
  return acqdata_list
250
271
  finally:
251
272
  self.pixel_array = original_pixel_array
252
- self.header["!number of projections"] = str(total_projections)
273
+ self.header = original_header
253
274
 
254
275
  def update_header_from_dicom(self, dicom_filepath):
255
276
  """
@@ -273,16 +294,18 @@ class STIRSPECTAcquisitionDataBuilder:
273
294
  "Modality not found in DICOM. Retaining default '!imaging modality'."
274
295
  )
275
296
  try:
276
- self.header["!matrix size [1]"] = str(ds.Rows)
277
- self.header["!matrix size [2]"] = str(ds.Columns)
297
+ self.header["!matrix size [1]"] = str(ds.Columns)
298
+ self.header["!matrix size [2]"] = str(ds.Rows)
278
299
  except AttributeError:
279
300
  warnings.warn(
280
301
  "Rows/Columns not found in DICOM. Retaining default matrix sizes."
281
302
  )
282
303
  try:
283
304
  pixel_spacing = ds.PixelSpacing
284
- self.header["scaling factor (mm/pixel) [1]"] = str(pixel_spacing[0])
285
- self.header["scaling factor (mm/pixel) [2]"] = str(pixel_spacing[1])
305
+ # DICOM PixelSpacing is [row spacing, column spacing]; Interfile
306
+ # [1] is the column/bin axis and [2] the row/axial axis.
307
+ self.header["scaling factor (mm/pixel) [1]"] = str(pixel_spacing[1])
308
+ self.header["scaling factor (mm/pixel) [2]"] = str(pixel_spacing[0])
286
309
  except AttributeError:
287
310
  warnings.warn(
288
311
  "PixelSpacing not found in DICOM. Retaining default scaling factors."
@@ -303,6 +326,10 @@ class STIRSPECTAcquisitionDataBuilder:
303
326
  try:
304
327
  ewi_seq_tag = (0x0054, 0x0012)
305
328
  self.energy_windows = [] # list to hold each energy window's info
329
+ vector = ds.get("EnergyWindowVector")
330
+ self.energy_window_vector = (
331
+ [int(v) for v in np.atleast_1d(vector)] if vector is not None else None
332
+ )
306
333
  if ewi_seq_tag in ds:
307
334
  ewi_seq = ds[ewi_seq_tag].value
308
335
  for ewi_item in ewi_seq:
@@ -111,6 +111,9 @@ class STIRSPECTImageDataBuilder:
111
111
  header_path = base_path.with_suffix(".hv")
112
112
  raw_path = base_path.with_suffix(".v")
113
113
  self.header["!name of data file"] = raw_path.name
114
+ # Keys added after construction must come before the terminator.
115
+ self.header.pop("!END OF INTERFILE", None)
116
+ self.header["!END OF INTERFILE"] = ""
114
117
 
115
118
  with open(header_path, "w") as f:
116
119
  line = 0
@@ -0,0 +1,83 @@
1
+ """Helpers for extracting voxel spacing from SIRF/STIR image objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+
8
+ def _call_or_value(value: Any) -> Any:
9
+ return value() if callable(value) else value
10
+
11
+
12
+ def _spacing_zyx(spacing: Any) -> Optional[tuple[float, float, float]]:
13
+ try:
14
+ values = tuple(float(v) for v in spacing)
15
+ except Exception:
16
+ values = ()
17
+
18
+ if len(values) >= 4:
19
+ # Raw STIR-style coordinate sequence: (unused, z, y, x)
20
+ return values[1], values[2], values[3]
21
+ if len(values) == 3:
22
+ # Public (z, y, x) convention
23
+ return values[0], values[1], values[2]
24
+
25
+ if all(hasattr(spacing, axis) for axis in ("z", "y", "x")):
26
+ try:
27
+ z, y, x = (float(_call_or_value(getattr(spacing, axis))) for axis in "zyx")
28
+ return z, y, x
29
+ except Exception:
30
+ pass
31
+
32
+ # Non-iterable STIR coordinates index (z, y, x) from 1.
33
+ for accessor in (
34
+ getattr(spacing, "at", None),
35
+ getattr(spacing, "__getitem__", None),
36
+ ):
37
+ if accessor is None:
38
+ continue
39
+ try:
40
+ z, y, x = (float(accessor(index)) for index in (1, 2, 3))
41
+ return z, y, x
42
+ except Exception:
43
+ continue
44
+
45
+ return None
46
+
47
+
48
+ def extract_voxel_size_mm(image: Any, backend_name: str) -> float:
49
+ """Return the voxel size in mm from backend image metadata.
50
+
51
+ Backend images report spacing in ``(z, y, x)`` order. The connector takes a
52
+ single voxel size, so the voxels must be cubic.
53
+ """
54
+ zyx = None
55
+ if hasattr(image, "voxel_sizes"):
56
+ try:
57
+ voxel_sizes = tuple(float(v) for v in image.voxel_sizes())
58
+ if len(voxel_sizes) >= 3:
59
+ zyx = voxel_sizes[:3]
60
+ except Exception:
61
+ pass
62
+
63
+ if zyx is None and hasattr(image, "get_grid_spacing"):
64
+ zyx = _spacing_zyx(image.get_grid_spacing())
65
+ if zyx is None:
66
+ raise ValueError(
67
+ f"Unable to read voxel spacing from {backend_name} get_grid_spacing()."
68
+ )
69
+
70
+ if zyx is None:
71
+ raise ValueError(
72
+ f"{backend_name} source object must expose voxel_sizes() or "
73
+ "get_grid_spacing()."
74
+ )
75
+ if max(zyx) - min(zyx) > 1e-3:
76
+ raise ValueError(
77
+ f"{backend_name} images must have cubic voxels; got (z, y, x) = "
78
+ f"{tuple(zyx)} mm"
79
+ )
80
+ return float(zyx[0])
81
+
82
+
83
+ __all__ = ["extract_voxel_size_mm"]
@@ -8,6 +8,7 @@ import contextlib
8
8
  import logging
9
9
  import math
10
10
  import os
11
+ import re
11
12
  import shutil
12
13
  from dataclasses import dataclass, field
13
14
  from pathlib import Path
@@ -68,8 +69,11 @@ class SimindPythonConnector(BaseConnector):
68
69
  self.output_dir.mkdir(parents=True, exist_ok=True)
69
70
  self.output_prefix = output_prefix
70
71
  self.quantization_scale = float(quantization_scale)
71
- if not math.isfinite(self.quantization_scale) or self.quantization_scale <= 0:
72
- raise ValueError("quantization_scale must be > 0")
72
+ # Above 1 the source would be clipped at MAX_SOURCE, changing its shape.
73
+ if not 0 < self.quantization_scale <= 1:
74
+ raise ValueError(
75
+ f"quantization_scale must be in (0, 1], got {quantization_scale!r}"
76
+ )
73
77
 
74
78
  self.config = self._initialize_config(config_source)
75
79
  self.runtime_switches = RuntimeSwitches()
@@ -82,16 +86,18 @@ class SimindPythonConnector(BaseConnector):
82
86
 
83
87
  @staticmethod
84
88
  def _validate_output_prefix(prefix: str) -> None:
85
- """Reject prefixes that could escape the output directory."""
86
- if (
87
- not isinstance(prefix, str)
88
- or not prefix
89
- or prefix in {".", ".."}
90
- or "/" in prefix
91
- or "\\" in prefix
92
- or Path(prefix).is_absolute()
89
+ """Accept only prefixes SIMIND keeps unchanged in its file names.
90
+
91
+ SIMIND lowercases file names on Unix, a dot would be taken for a file
92
+ extension, and glob characters would break output discovery.
93
+ """
94
+ if not isinstance(prefix, str) or not re.fullmatch(
95
+ r"[a-z0-9][a-z0-9_-]*", prefix
93
96
  ):
94
- raise ValueError(f"output_prefix must be a plain filename, got {prefix!r}")
97
+ raise ValueError(
98
+ "output_prefix must contain only lowercase letters, digits, '_' "
99
+ f"and '-', got {prefix!r}"
100
+ )
95
101
 
96
102
  @staticmethod
97
103
  def _initialize_config(config_source: ConfigSource) -> SimulationConfig:
@@ -136,7 +142,9 @@ class SimindPythonConnector(BaseConnector):
136
142
 
137
143
  The projection pixel size and matrix (Index 28, 76, 77) come from the
138
144
  configuration, not from the phantom. scoring_routine overrides Index 84;
139
- None keeps the configuration value.
145
+ None keeps the configuration value. The connector reads SPECT Interfile
146
+ output, so this sets Flag 5 (SPECT) and Flag 14 (Interfile headers).
147
+ The mu map is taken to be at abs(Index 1) keV.
140
148
 
141
149
  Returns:
142
150
  Tuple of (source_file_path, density_file_path).
@@ -222,7 +230,9 @@ class SimindPythonConnector(BaseConnector):
222
230
  cfg.set_data_file(6, src_prefix)
223
231
 
224
232
  if cfg.get_flag(11):
225
- photon_energy = float(cfg.get_value("photon_energy"))
233
+ # A negative Index 1 asks SIMIND to read photon energies from the
234
+ # isotope file; its absolute value is still the window energy.
235
+ photon_energy = abs(float(cfg.get_value("photon_energy")))
226
236
  density = attenuation_to_density(mu_map_array, photon_energy) * 1000.0
227
237
  else:
228
238
  density = np.zeros_like(mu_map_array)
@@ -290,6 +300,10 @@ class SimindPythonConnector(BaseConnector):
290
300
  fov_length_mm,
291
301
  )
292
302
 
303
+ # Outputs are read from SPECT Interfile headers (.h00), which SIMIND
304
+ # writes only with Flag 5 (SPECT) and Flag 14 (Interfile) set.
305
+ self.config.set_flag(5, True)
306
+ self.config.set_flag(14, True)
293
307
  config_path = self.output_dir / self.output_prefix
294
308
  self.config.save_file(config_path)
295
309
 
@@ -415,12 +429,9 @@ class SimindPythonConnector(BaseConnector):
415
429
  try:
416
430
  interfile = load_interfile_array(header_path)
417
431
  except Exception as exc:
418
- self.logger.warning(
419
- "Skipping output %s due to parse/load error: %s",
420
- header_path,
421
- exc,
422
- )
423
- continue
432
+ raise RuntimeError(
433
+ f"SIMIND output {header_path} could not be read: {exc}"
434
+ ) from exc
424
435
 
425
436
  key = self._extract_output_key(header_path)
426
437
  outputs[key] = ProjectionResult(
@@ -430,11 +441,6 @@ class SimindPythonConnector(BaseConnector):
430
441
  metadata=interfile.metadata,
431
442
  )
432
443
 
433
- if not outputs:
434
- raise RuntimeError(
435
- f"No valid outputs were parsed from headers in {self.output_dir}"
436
- )
437
-
438
444
  return outputs
439
445
 
440
446
  def _extract_output_key(self, header_path: Path) -> str: