simind-python-connector 1.0.1__tar.gz → 1.1.0__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 (134) hide show
  1. {simind_python_connector-1.0.1/simind_python_connector.egg-info → simind_python_connector-1.1.0}/PKG-INFO +1 -1
  2. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/changelog.rst +70 -0
  3. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/conf.py +1 -1
  4. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0/docs}/examples/07A_stir_adaptor_osem.py +4 -0
  5. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0/docs}/examples/07B_sirf_adaptor_osem.py +4 -0
  6. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/examples/07C_pytomography_adaptor_osem.py +4 -0
  7. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/geometry.rst +28 -4
  8. {simind_python_connector-1.0.1/docs → simind_python_connector-1.1.0}/examples/07A_stir_adaptor_osem.py +4 -0
  9. {simind_python_connector-1.0.1/docs → simind_python_connector-1.1.0}/examples/07B_sirf_adaptor_osem.py +4 -0
  10. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/examples/07C_pytomography_adaptor_osem.py +4 -0
  11. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/pyproject.toml +1 -1
  12. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/__init__.py +1 -1
  13. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/backends/__init__.py +7 -0
  14. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/builders/acquisition_builder.py +77 -29
  15. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/builders/image_builder.py +18 -6
  16. simind_python_connector-1.1.0/simind_python_connector/connectors/__init__.py +43 -0
  17. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/connectors/_spacing.py +13 -7
  18. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/connectors/python_connector.py +146 -27
  19. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/connectors/pytomography_adaptor.py +5 -0
  20. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/connectors/sirf_adaptor.py +3 -0
  21. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/connectors/stir_adaptor.py +3 -0
  22. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/converters/attenuation.py +3 -1
  23. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/converters/simind_to_stir.py +12 -10
  24. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/core/config.py +208 -66
  25. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/core/executor.py +36 -7
  26. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/utils/import_helpers.py +4 -0
  27. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/utils/interfile_numpy.py +18 -8
  28. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/utils/interfile_parser.py +4 -2
  29. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/utils/simind_utils.py +50 -11
  30. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/utils/stir_utils.py +7 -2
  31. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0/simind_python_connector.egg-info}/PKG-INFO +1 -1
  32. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector.egg-info/SOURCES.txt +7 -0
  33. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_acquisition_builder_unit.py +77 -2
  34. simind_python_connector-1.1.0/tests/test_attenuation_utils.py +116 -0
  35. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_backends.py +78 -0
  36. simind_python_connector-1.1.0/tests/test_connector_backend_separation.py +118 -0
  37. simind_python_connector-1.1.0/tests/test_container_scripts.py +290 -0
  38. simind_python_connector-1.1.0/tests/test_dicom_builder.py +156 -0
  39. simind_python_connector-1.1.0/tests/test_executor.py +217 -0
  40. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_image_builder_unit.py +23 -0
  41. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_interfile_numpy.py +128 -4
  42. simind_python_connector-1.1.0/tests/test_legacy_surface_policy.py +20 -0
  43. simind_python_connector-1.1.0/tests/test_marker_policy.py +117 -0
  44. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_native_adaptors.py +108 -12
  45. simind_python_connector-1.1.0/tests/test_non_square_slices_simind.py +64 -0
  46. simind_python_connector-1.1.0/tests/test_packaging_resources.py +113 -0
  47. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_python_connector.py +318 -0
  48. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_pytomography_adaptor.py +85 -0
  49. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_schneider_density.py +3 -0
  50. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_simind_to_stir_converter.py +41 -9
  51. simind_python_connector-1.1.0/tests/test_simulation_config.py +208 -0
  52. simind_python_connector-1.1.0/tests/test_utils_small.py +96 -0
  53. simind_python_connector-1.0.1/simind_python_connector/connectors/__init__.py +0 -24
  54. simind_python_connector-1.0.1/tests/test_connector_backend_separation.py +0 -42
  55. simind_python_connector-1.0.1/tests/test_marker_policy.py +0 -72
  56. simind_python_connector-1.0.1/tests/test_simulation_config.py +0 -89
  57. simind_python_connector-1.0.1/tests/test_utils_small.py +0 -53
  58. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/LICENSE +0 -0
  59. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/MANIFEST.in +0 -0
  60. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/README.md +0 -0
  61. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/api.rst +0 -0
  62. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/backends.rst +0 -0
  63. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/connector_modernization_plan.rst +0 -0
  64. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/contributing.rst +0 -0
  65. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/examples/01_basic_simulation.py +0 -0
  66. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/examples/02_runtime_switch_comparison.py +0 -0
  67. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/examples/03_multi_window.py +0 -0
  68. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/examples/04_custom_config.py +0 -0
  69. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/examples/05_scattwin_vs_penetrate_comparison.py +0 -0
  70. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/examples/06_schneider_density_conversion.py +0 -0
  71. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/examples/08A_stir_adaptor_from_dicom.py +0 -0
  72. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/examples/08B_sirf_adaptor_from_dicom.py +0 -0
  73. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/examples/08C_pytomography_adaptor_from_dicom.py +0 -0
  74. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/examples/_python_connector_helpers.py +0 -0
  75. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/examples.rst +0 -0
  76. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/index.rst +0 -0
  77. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/installation.rst +0 -0
  78. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/intro.rst +0 -0
  79. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/testing.rst +0 -0
  80. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/docs/usage.rst +0 -0
  81. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/examples/01_basic_simulation.py +0 -0
  82. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/examples/02_runtime_switch_comparison.py +0 -0
  83. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/examples/03_multi_window.py +0 -0
  84. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/examples/04_custom_config.py +0 -0
  85. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/examples/05_scattwin_vs_penetrate_comparison.py +0 -0
  86. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/examples/06_schneider_density_conversion.py +0 -0
  87. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/examples/08A_stir_adaptor_from_dicom.py +0 -0
  88. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/examples/08B_sirf_adaptor_from_dicom.py +0 -0
  89. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/examples/08C_pytomography_adaptor_from_dicom.py +0 -0
  90. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/examples/_python_connector_helpers.py +0 -0
  91. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/requirements-dev.txt +0 -0
  92. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/requirements.txt +0 -0
  93. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/setup.cfg +0 -0
  94. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/backends/base.py +0 -0
  95. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/backends/sirf_backend.py +0 -0
  96. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/backends/stir_backend.py +0 -0
  97. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/builders/__init__.py +0 -0
  98. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/configs/AnyScan.yaml +0 -0
  99. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/configs/Discovery670.yaml +0 -0
  100. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/configs/Example.yaml +0 -0
  101. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/configs/MLD001_SCAN0.yaml +0 -0
  102. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/configs/__init__.py +0 -0
  103. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/configs/input.smc +0 -0
  104. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/connectors/base.py +0 -0
  105. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/converters/__init__.py +0 -0
  106. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/converters/dicom_to_stir.py +0 -0
  107. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/core/__init__.py +0 -0
  108. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/core/types.py +0 -0
  109. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/data/Schneider2000.json +0 -0
  110. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/data/__init__.py +0 -0
  111. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/data/bone.atn +0 -0
  112. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/data/h2o.atn +0 -0
  113. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/utils/__init__.py +0 -0
  114. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/utils/backend_access.py +0 -0
  115. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/utils/io_utils.py +0 -0
  116. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector/utils/sirf_stir_utils.py +0 -0
  117. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector.egg-info/dependency_links.txt +0 -0
  118. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector.egg-info/requires.txt +0 -0
  119. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/simind_python_connector.egg-info/top_level.txt +0 -0
  120. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/__init__.py +0 -0
  121. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/conftest.py +0 -0
  122. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_arithmetic_operations.py +0 -0
  123. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_connectors_base.py +0 -0
  124. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_container_library_isolation.py +0 -0
  125. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_example_config_profile.py +0 -0
  126. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_examples_backend_separation.py +0 -0
  127. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_geometry_isolation_forward_projection.py +0 -0
  128. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_integration.py +0 -0
  129. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_interfile_parser.py +0 -0
  130. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_osem_geometry_diagnostics.py +0 -0
  131. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_package_identity.py +0 -0
  132. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_sirf_stir_utils.py +0 -0
  133. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_stir_utils_backend_choice_unit.py +0 -0
  134. {simind_python_connector-1.0.1 → simind_python_connector-1.1.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simind-python-connector
3
- Version: 1.0.1
3
+ Version: 1.1.0
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>
@@ -7,6 +7,76 @@ 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.0 - 2026-10-11
11
+ ------------------
12
+
13
+ Replaces 2.0.0 and 3.0.0, which were published by mistake and have been
14
+ withdrawn.
15
+
16
+ Breaking Changes
17
+ ~~~~~~~~~~~~~~~~
18
+
19
+ - The projection bin size and matrix (Index 28, 76, 77) come from the
20
+ configuration. ``configure_voxel_phantom`` no longer sets them from the
21
+ phantom image, which made the projections square whenever the image slices
22
+ were square and took the number of axial rows from the image height.
23
+ - ``configure_voxel_phantom`` keeps the configured scoring routine (Index 84)
24
+ unless ``scoring_routine`` is given.
25
+ - ``STIRSPECTAcquisitionDataBuilder`` no longer flips projection data along the
26
+ last axis.
27
+ - Runtime-operator switches passed to ``run()`` apply to that run only. They
28
+ are no longer kept on the connector for later runs.
29
+ - Each run deletes earlier outputs that share the output prefix before SIMIND
30
+ starts.
31
+ - The STIR backend needs ``stirextra`` as well as ``stir``.
32
+ - STIR/SIRF adaptors take the z voxel spacing from a different axis (see
33
+ Fixed). Results change for anisotropic voxels.
34
+ - Invalid inputs now raise ``ValueError``: empty, non-finite or negative source
35
+ and attenuation maps, array shapes that do not match the header, and
36
+ ``output_prefix`` values that contain a path. Energy-window validation raises
37
+ ``ValueError`` instead of ``AssertionError``.
38
+
39
+ Added
40
+ ~~~~~
41
+
42
+ - ``run()`` warns when a voxel phantom is wider or longer than the projection
43
+ field of view.
44
+
45
+ Fixed
46
+ ~~~~~
47
+
48
+ - The packaged ``configs/input.smc`` is now included in the wheel. SMC parsing
49
+ handles fixed-width fields and checks section counts.
50
+ - STIR/SIRF adaptors read the z voxel spacing from the correct axis:
51
+ ``voxel_sizes()`` is ``(z, y, x)``, and raw ``get_grid_spacing()`` is
52
+ ``(unused, z, y, x)``.
53
+ - Voxel-map dimension indices 78, 79, 81 and 82 are set correctly.
54
+ - The executor resolves ``SIMIND_BIN`` and accepts executable paths that
55
+ contain spaces. The connector no longer changes the process working directory.
56
+ - Runtime switches apply to one run only. Output prefixes cannot escape the
57
+ output directory. Stale outputs are removed, and ``.win``, ``.smi`` and
58
+ ``.dmi`` inputs are kept.
59
+ - Energy-window validation raises ``ValueError`` instead of failing an
60
+ ``assert``.
61
+ - The Interfile loader honours data offsets, strips quoted values and rejects
62
+ truncated or oversized payloads. The SIMIND-to-STIR converter keeps data-file
63
+ lines and applies the radius scale factor.
64
+ - Attenuation utilities handle the ``rho*1000`` selector and STIR ``#`` header
65
+ keys, and reject unknown attenuation types.
66
+ - Image and acquisition builders validate array shapes, split multi-energy data
67
+ deterministically and no longer flip images. The DICOM builder handles missing
68
+ timing information and 2D pixel data.
69
+ - Backend adaptors are imported lazily, so the core package imports without
70
+ STIR, SIRF or PyTomography.
71
+
72
+ Removed
73
+ ~~~~~~~
74
+
75
+ - The legacy ``scripts/simulation.py``.
76
+ - The "non-square matrix" warning from
77
+ ``SimulationConfig.validate_parameters``; SIMIND supports non-square
78
+ projections.
79
+
10
80
  1.0.1 - 2026-04-16
11
81
  ------------------
12
82
 
@@ -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.0.1"
16
+ release = "1.1.0"
17
17
  for dist_name in ("simind-python-connector",):
18
18
  try:
19
19
  release = importlib_metadata.version(dist_name)
@@ -188,6 +188,10 @@ def main() -> None:
188
188
  adaptor.add_config_value(1, 140.0)
189
189
  adaptor.add_config_value(19, 2)
190
190
  adaptor.add_config_value(53, 0)
191
+ # Projections on the reconstruction grid: 32 x 32 bins of 4 mm (Index 28 in cm).
192
+ adaptor.add_config_value(28, 0.4)
193
+ adaptor.add_config_value(76, 32)
194
+ adaptor.add_config_value(77, 32)
191
195
  adaptor.run()
192
196
 
193
197
  projection = adaptor.get_total_output(window=1)
@@ -170,6 +170,10 @@ def main() -> None:
170
170
  adaptor.add_config_value(1, 140.0)
171
171
  adaptor.add_config_value(19, 2)
172
172
  adaptor.add_config_value(53, 0)
173
+ # Projections on the reconstruction grid: 32 x 32 bins of 4 mm (Index 28 in cm).
174
+ adaptor.add_config_value(28, 0.4)
175
+ adaptor.add_config_value(76, 32)
176
+ adaptor.add_config_value(77, 32)
173
177
  adaptor.run()
174
178
 
175
179
  projection = adaptor.get_total_output(window=1)
@@ -144,6 +144,10 @@ def main() -> None:
144
144
  adaptor.add_config_value(1, 140.0)
145
145
  adaptor.add_config_value(19, 2)
146
146
  adaptor.add_config_value(53, 0)
147
+ # Projections on the reconstruction grid: 32 x 32 bins of 4 mm (Index 28 in cm).
148
+ adaptor.add_config_value(28, 0.4)
149
+ adaptor.add_config_value(76, 32)
150
+ adaptor.add_config_value(77, 32)
147
151
  adaptor.run()
148
152
 
149
153
  projection_tensor = adaptor.get_total_output(window=1).to(dtype=torch.float32)
@@ -35,10 +35,32 @@ 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. It uses
39
- ``voxel_sizes()[2]`` when available, and falls back to ``get_grid_spacing()``
40
- using the last spatial element (index 3 for 4-element spacing, index 2 for
41
- 3-element spacing).
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.
43
+
44
+ Projection Size
45
+ ---------------
46
+
47
+ The projection bin size and matrix come from the configuration: Index 28 (bin
48
+ size in cm), Index 76 (bins) and Index 77 (axial rows). Configuring a voxel
49
+ phantom does not change them, so set them to cover the phantom; ``run()`` warns
50
+ when the phantom is wider or longer than the projection field of view. The
51
+ matrix does not have to be square.
52
+
53
+ Some projectors need the projections sampled like the image. STIR's SPECTUB
54
+ needs equal axial sampling, so the OSEM examples set Index 28 to the voxel size.
55
+
56
+ Voxel Phantom Slices
57
+ --------------------
58
+
59
+ Image slices do not have to be square. The columns (x, Index 78 and 79) and
60
+ rows (y, Index 81 and 82) of the source and density maps are set separately,
61
+ and a SIMIND test checks that non-square slices give the same projections as
62
+ the same slices padded to square. The voxels themselves must be square in-plane:
63
+ SIMIND takes a single voxel side (Index 31 and the ``PX`` switch).
42
64
 
43
65
  Example Configuration Guardrails
44
66
  --------------------------------
@@ -50,6 +72,8 @@ are reproducible:
50
72
  - ``config[29]=24`` for projection count.
51
73
  - ``config[53]=0`` to keep collimator modeling geometric-only in these tests.
52
74
  - ``config[19]=2`` to keep a consistent mapping used by current examples.
75
+ - ``config[28]=0.4``, ``config[76]=32`` and ``config[77]=32`` to put the
76
+ projections on the 4 mm, 32-voxel reconstruction grid.
53
77
 
54
78
  Debug Checklist
55
79
  ---------------
@@ -188,6 +188,10 @@ def main() -> None:
188
188
  adaptor.add_config_value(1, 140.0)
189
189
  adaptor.add_config_value(19, 2)
190
190
  adaptor.add_config_value(53, 0)
191
+ # Projections on the reconstruction grid: 32 x 32 bins of 4 mm (Index 28 in cm).
192
+ adaptor.add_config_value(28, 0.4)
193
+ adaptor.add_config_value(76, 32)
194
+ adaptor.add_config_value(77, 32)
191
195
  adaptor.run()
192
196
 
193
197
  projection = adaptor.get_total_output(window=1)
@@ -170,6 +170,10 @@ def main() -> None:
170
170
  adaptor.add_config_value(1, 140.0)
171
171
  adaptor.add_config_value(19, 2)
172
172
  adaptor.add_config_value(53, 0)
173
+ # Projections on the reconstruction grid: 32 x 32 bins of 4 mm (Index 28 in cm).
174
+ adaptor.add_config_value(28, 0.4)
175
+ adaptor.add_config_value(76, 32)
176
+ adaptor.add_config_value(77, 32)
173
177
  adaptor.run()
174
178
 
175
179
  projection = adaptor.get_total_output(window=1)
@@ -144,6 +144,10 @@ def main() -> None:
144
144
  adaptor.add_config_value(1, 140.0)
145
145
  adaptor.add_config_value(19, 2)
146
146
  adaptor.add_config_value(53, 0)
147
+ # Projections on the reconstruction grid: 32 x 32 bins of 4 mm (Index 28 in cm).
148
+ adaptor.add_config_value(28, 0.4)
149
+ adaptor.add_config_value(76, 32)
150
+ adaptor.add_config_value(77, 32)
147
151
  adaptor.run()
148
152
 
149
153
  projection_tensor = adaptor.get_total_output(window=1).to(dtype=torch.float32)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simind-python-connector"
7
- version = "1.0.1"
7
+ version = "1.1.0"
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.0.1"
17
+ __version__ = "1.1.0"
18
18
 
19
19
 
20
20
  def __getattr__(name: str) -> Any:
@@ -19,6 +19,7 @@ Usage:
19
19
 
20
20
  import importlib
21
21
  import logging
22
+ import os
22
23
  from typing import TYPE_CHECKING, Any, Literal, Optional, Union
23
24
 
24
25
  from .base import AcquisitionDataInterface, ImageDataInterface
@@ -233,6 +234,9 @@ def create_image_data(
233
234
  if isinstance(filepath_or_object, ImageDataInterface):
234
235
  return filepath_or_object
235
236
 
237
+ if isinstance(filepath_or_object, os.PathLike):
238
+ filepath_or_object = os.fspath(filepath_or_object)
239
+
236
240
  backend_hint = detect_image_backend(filepath_or_object)
237
241
  if backend_hint is None:
238
242
  backend_hint = detect_backend_from_interface(filepath_or_object)
@@ -314,6 +318,9 @@ def create_acquisition_data(
314
318
  if isinstance(filepath_or_object, AcquisitionDataInterface):
315
319
  return filepath_or_object
316
320
 
321
+ if isinstance(filepath_or_object, os.PathLike):
322
+ filepath_or_object = os.fspath(filepath_or_object)
323
+
317
324
  backend_hint = detect_acquisition_backend(filepath_or_object)
318
325
  if backend_hint is None:
319
326
  backend_hint = detect_backend_from_interface(filepath_or_object)
@@ -97,6 +97,19 @@ class STIRSPECTAcquisitionDataBuilder:
97
97
  else:
98
98
  self.pixel_array = np.array(self.pixel_array, dtype=np.float32)
99
99
 
100
+ expected_shape = (
101
+ 1,
102
+ matrix_size_1,
103
+ num_projections,
104
+ matrix_size_2,
105
+ )
106
+ if self.pixel_array.shape != expected_shape:
107
+ raise ValueError(
108
+ f"pixel array shape {self.pixel_array.shape} does not match "
109
+ f"header dimensions {expected_shape} "
110
+ "(tof, bin, view, axial)"
111
+ )
112
+
100
113
  def _write(base_path: Path, cleanup: bool) -> AcquisitionData:
101
114
  header_path = base_path.with_suffix(".hs")
102
115
  raw_file_path = base_path.with_suffix(".s")
@@ -112,9 +125,8 @@ class STIRSPECTAcquisitionDataBuilder:
112
125
 
113
126
  acqdata = self._load_acquisition(str(header_path))
114
127
 
115
- flipped = np.flip(self.pixel_array, axis=-1)
116
128
  acqdata = acqdata.clone()
117
- acqdata.fill(flipped)
129
+ acqdata.fill(self.pixel_array)
118
130
  acqdata.write(str(header_path))
119
131
 
120
132
  if cleanup:
@@ -194,27 +206,50 @@ class STIRSPECTAcquisitionDataBuilder:
194
206
  acqdata = self.build(output_path=output_path_base)
195
207
  return [acqdata]
196
208
 
209
+ if self.pixel_array is None:
210
+ raise ValueError(
211
+ "build_multi_energy requires pixel_array to be set "
212
+ "(use update_header_from_dicom or set it directly)"
213
+ )
214
+
215
+ num_windows = len(self.energy_windows)
216
+ total_projections = int(self.header.get("!number of projections", 1))
217
+ if self.pixel_array.ndim != 4 or (
218
+ self.pixel_array.shape[2] != total_projections
219
+ ):
220
+ raise ValueError(
221
+ f"pixel array shape {self.pixel_array.shape} does not match "
222
+ f"declared {total_projections} projections"
223
+ )
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
+
197
231
  # number of projections needs dividing by number of energy windows
198
- num_projections = int(self.header.get("!number of projections", 1))
199
- num_projections //= len(self.energy_windows)
200
- self.header["!number of projections"] = str(num_projections)
232
+ self.header["!number of projections"] = str(projections_per_window)
201
233
 
202
234
  # split pixel_array into energy windows along 3rd axis
203
- pixel_array_list = np.array_split(
204
- self.pixel_array, len(self.energy_windows), axis=2
205
- )
206
-
207
- acqdata_list = []
208
- for idx, ew in enumerate(self.energy_windows):
209
- # Update header for this energy window.
210
- self.header["energy window lower level[1]"] = ew["lower"]
211
- self.header["energy window upper level[1]"] = ew["upper"]
212
- suffix = f"_ew{idx + 1}"
213
- output_path = output_path_base + suffix
214
- self.pixel_array = pixel_array_list[idx]
215
- acqdata = self.build(output_path=output_path)
216
- acqdata_list.append(acqdata)
217
- return acqdata_list
235
+ original_pixel_array = self.pixel_array
236
+ try:
237
+ pixel_array_list = np.array_split(original_pixel_array, num_windows, axis=2)
238
+
239
+ acqdata_list = []
240
+ for idx, ew in enumerate(self.energy_windows):
241
+ # Update header for this energy window.
242
+ self.header["energy window lower level[1]"] = ew["lower"]
243
+ 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]
247
+ acqdata = self.build(output_path=output_path)
248
+ acqdata_list.append(acqdata)
249
+ return acqdata_list
250
+ finally:
251
+ self.pixel_array = original_pixel_array
252
+ self.header["!number of projections"] = str(total_projections)
218
253
 
219
254
  def update_header_from_dicom(self, dicom_filepath):
220
255
  """
@@ -303,6 +338,8 @@ class STIRSPECTAcquisitionDataBuilder:
303
338
  )
304
339
 
305
340
  # Rotation Information Sequence processing
341
+ num_frames = ds.get("NumberOfFrames", None)
342
+ time_per_projection = None
306
343
  try:
307
344
  if (0x0054, 0x0052) in ds:
308
345
  rot_seq = ds[(0x0054, 0x0052)].value
@@ -322,16 +359,17 @@ class STIRSPECTAcquisitionDataBuilder:
322
359
  rot_item[(0x0018, 0x1242)].value / 1000
323
360
  )
324
361
 
325
- if num_frames is not None:
362
+ if time_per_projection is not None and num_frames is not None:
326
363
  self.header["number of time frames"] = str(1)
327
364
  self.header["!image duration (sec)[1]"] = str(
328
365
  int(
329
366
  np.round(
330
- float(time_per_projection) * float(num_frames), 0
367
+ float(time_per_projection) * float(num_frames),
368
+ 0,
331
369
  )
332
370
  )
333
371
  )
334
- else:
372
+ elif time_per_projection is not None:
335
373
  self.header["!time per projection (sec)[1]"] = (
336
374
  time_per_projection
337
375
  )
@@ -516,11 +554,21 @@ class STIRSPECTAcquisitionDataBuilder:
516
554
  except AttributeError:
517
555
  warnings.warn("StudyDescription not found in DICOM.")
518
556
  try:
519
- self.pixel_array = ds.pixel_array
520
- print(self.pixel_array.shape)
521
- self.pixel_array = np.transpose(self.pixel_array, (2, 0, 1))
522
- # rotate the image by 90 degrees cW in axis 1
523
- self.pixel_array = np.rot90(self.pixel_array, 3, axes=(0, 2))
524
- self.pixel_array = np.expand_dims(self.pixel_array, axis=0)
557
+ raw_pixel_array = ds.pixel_array
525
558
  except AttributeError:
526
559
  warnings.warn("Pixel data not found in DICOM.")
560
+ return
561
+
562
+ raw_pixel_array = np.asarray(raw_pixel_array)
563
+ if raw_pixel_array.ndim == 2:
564
+ # Single frame: promote to (frames=1, rows, columns) so the
565
+ # transpose below sees the same layout as multiframe data.
566
+ raw_pixel_array = raw_pixel_array[np.newaxis, :, :]
567
+ elif raw_pixel_array.ndim != 3:
568
+ raise ValueError(
569
+ f"pixel data must be 2D or 3D, got {raw_pixel_array.ndim}D"
570
+ )
571
+ self.pixel_array = np.transpose(raw_pixel_array, (2, 0, 1))
572
+ # rotate the image by 90 degrees cW in axis 1
573
+ self.pixel_array = np.rot90(self.pixel_array, 3, axes=(0, 2))
574
+ self.pixel_array = np.expand_dims(self.pixel_array, axis=0)
@@ -79,12 +79,24 @@ class STIRSPECTImageDataBuilder:
79
79
 
80
80
  def _resolve_data_array(self) -> np.ndarray:
81
81
  if self.pixel_array is not None:
82
- return np.asarray(self.pixel_array, dtype=np.float32)
83
-
84
- dim_x = int(self.header["!matrix size [1]"])
85
- dim_y = int(self.header["!matrix size [2]"])
86
- dim_z = int(self.header["!matrix size [3]"])
87
- return np.zeros((dim_z, dim_y, dim_x), dtype=np.float32)
82
+ data = np.asarray(self.pixel_array, dtype=np.float32)
83
+ else:
84
+ dim_x = int(self.header["!matrix size [1]"])
85
+ dim_y = int(self.header["!matrix size [2]"])
86
+ dim_z = int(self.header["!matrix size [3]"])
87
+ data = np.zeros((dim_z, dim_y, dim_x), dtype=np.float32)
88
+
89
+ expected_shape = (
90
+ int(self.header["!matrix size [3]"]),
91
+ int(self.header["!matrix size [2]"]),
92
+ int(self.header["!matrix size [1]"]),
93
+ )
94
+ if data.shape != expected_shape:
95
+ raise ValueError(
96
+ f"pixel array shape {data.shape} does not match header "
97
+ f"dimensions {expected_shape} (z, y, x)"
98
+ )
99
+ return data
88
100
 
89
101
  def build(self, output_path: Optional[str | Path] = None):
90
102
  """
@@ -0,0 +1,43 @@
1
+ """Connector/adaptor APIs.
2
+
3
+ Backend-specific adaptors are exposed lazily so that importing this package
4
+ never imports SIRF, STIR, or PyTomography.
5
+ """
6
+
7
+ import importlib
8
+
9
+ from .base import BaseConnector
10
+ from .python_connector import (
11
+ NumpyConnector,
12
+ ProjectionResult,
13
+ RuntimeOperator,
14
+ SimindPythonConnector,
15
+ )
16
+
17
+
18
+ _LAZY_ADAPTORS = {
19
+ "PyTomographySimindAdaptor": "pytomography_adaptor",
20
+ "SirfSimindAdaptor": "sirf_adaptor",
21
+ "StirSimindAdaptor": "stir_adaptor",
22
+ }
23
+
24
+
25
+ def __getattr__(name):
26
+ if name in _LAZY_ADAPTORS:
27
+ module = importlib.import_module(f".{_LAZY_ADAPTORS[name]}", __name__)
28
+ obj = getattr(module, name)
29
+ globals()[name] = obj
30
+ return obj
31
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
32
+
33
+
34
+ __all__ = [
35
+ "BaseConnector",
36
+ "NumpyConnector",
37
+ "PyTomographySimindAdaptor",
38
+ "ProjectionResult",
39
+ "RuntimeOperator",
40
+ "SirfSimindAdaptor",
41
+ "SimindPythonConnector",
42
+ "StirSimindAdaptor",
43
+ ]
@@ -17,9 +17,11 @@ def _voxel_size_from_spacing(spacing: Any) -> float | None:
17
17
 
18
18
  if values:
19
19
  if len(values) >= 4:
20
- return float(values[3])
21
- if len(values) >= 3:
22
- return float(values[2])
20
+ # Raw STIR-style coordinate sequence: (unused, z, y, x)
21
+ return float(values[1])
22
+ if len(values) == 3:
23
+ # Public (z, y, x) convention
24
+ return float(values[0])
23
25
 
24
26
  if hasattr(spacing, "z"):
25
27
  try:
@@ -28,13 +30,13 @@ def _voxel_size_from_spacing(spacing: Any) -> float | None:
28
30
  pass
29
31
 
30
32
  if hasattr(spacing, "at"):
31
- for index in (3, 2):
33
+ for index in (1, 2, 3):
32
34
  try:
33
35
  return float(spacing.at(index))
34
36
  except Exception:
35
37
  continue
36
38
 
37
- for index in (3, 2):
39
+ for index in (1, 2, 3):
38
40
  try:
39
41
  return float(spacing[index])
40
42
  except Exception:
@@ -44,12 +46,16 @@ def _voxel_size_from_spacing(spacing: Any) -> float | None:
44
46
 
45
47
 
46
48
  def extract_voxel_size_mm(image: Any, backend_name: str) -> float:
47
- """Extract z voxel spacing in mm from backend image metadata."""
49
+ """Extract z voxel spacing in mm from backend image metadata.
50
+
51
+ Backend images report ``voxel_sizes()`` in ``(z, y, x)`` order, so the
52
+ first element is the z spacing.
53
+ """
48
54
  if hasattr(image, "voxel_sizes"):
49
55
  voxel_sizes = image.voxel_sizes()
50
56
  try:
51
57
  if len(voxel_sizes) >= 3:
52
- return float(voxel_sizes[2])
58
+ return float(voxel_sizes[0])
53
59
  except Exception:
54
60
  pass
55
61