simind-python-connector 1.0.0__py3-none-any.whl
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.
- simind_python_connector/__init__.py +72 -0
- simind_python_connector/backends/__init__.py +480 -0
- simind_python_connector/backends/base.py +387 -0
- simind_python_connector/backends/sirf_backend.py +309 -0
- simind_python_connector/backends/stir_backend.py +395 -0
- simind_python_connector/builders/__init__.py +19 -0
- simind_python_connector/builders/acquisition_builder.py +526 -0
- simind_python_connector/builders/image_builder.py +217 -0
- simind_python_connector/configs/AnyScan.yaml +420 -0
- simind_python_connector/configs/Discovery670.yaml +412 -0
- simind_python_connector/configs/Example.yaml +420 -0
- simind_python_connector/configs/MLD001_SCAN0.yaml +426 -0
- simind_python_connector/configs/__init__.py +41 -0
- simind_python_connector/configs/input.smc +51 -0
- simind_python_connector/connectors/__init__.py +24 -0
- simind_python_connector/connectors/_spacing.py +69 -0
- simind_python_connector/connectors/base.py +40 -0
- simind_python_connector/connectors/python_connector.py +355 -0
- simind_python_connector/connectors/pytomography_adaptor.py +263 -0
- simind_python_connector/connectors/sirf_adaptor.py +164 -0
- simind_python_connector/connectors/stir_adaptor.py +164 -0
- simind_python_connector/converters/__init__.py +16 -0
- simind_python_connector/converters/attenuation.py +367 -0
- simind_python_connector/converters/dicom_to_stir.py +3 -0
- simind_python_connector/converters/simind_to_stir.py +769 -0
- simind_python_connector/core/__init__.py +7 -0
- simind_python_connector/core/config.py +939 -0
- simind_python_connector/core/executor.py +96 -0
- simind_python_connector/core/types.py +203 -0
- simind_python_connector/data/Schneider2000.json +222 -0
- simind_python_connector/data/__init__.py +25 -0
- simind_python_connector/data/bone.atn +187 -0
- simind_python_connector/data/h2o.atn +92 -0
- simind_python_connector/utils/__init__.py +120 -0
- simind_python_connector/utils/backend_access.py +121 -0
- simind_python_connector/utils/import_helpers.py +74 -0
- simind_python_connector/utils/interfile_numpy.py +195 -0
- simind_python_connector/utils/interfile_parser.py +175 -0
- simind_python_connector/utils/io_utils.py +14 -0
- simind_python_connector/utils/simind_utils.py +70 -0
- simind_python_connector/utils/sirf_stir_utils.py +194 -0
- simind_python_connector/utils/stir_utils.py +485 -0
- simind_python_connector-1.0.0.dist-info/METADATA +274 -0
- simind_python_connector-1.0.0.dist-info/RECORD +47 -0
- simind_python_connector-1.0.0.dist-info/WHEEL +5 -0
- simind_python_connector-1.0.0.dist-info/licenses/LICENSE +195 -0
- simind_python_connector-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Attenuation coefficient conversion utilities.
|
|
3
|
+
|
|
4
|
+
This module provides functions to convert Hounsfield Units (HU) to attenuation
|
|
5
|
+
coefficients and densities based on both bilinear and Schneider piecewise models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.resources as pkg_resources
|
|
9
|
+
import json
|
|
10
|
+
import warnings
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from simind_python_connector.data import data_path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_package_data_path(filename):
|
|
19
|
+
"""Get the path to a data file in the package."""
|
|
20
|
+
try:
|
|
21
|
+
# Python 3.9+
|
|
22
|
+
files = pkg_resources.files("simind_python_connector.data")
|
|
23
|
+
return files / filename
|
|
24
|
+
except AttributeError:
|
|
25
|
+
# Python 3.8
|
|
26
|
+
with pkg_resources.path("simind_python_connector.data", filename) as path:
|
|
27
|
+
return path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def interpolate_attenuation_coefficient(filename, energy):
|
|
31
|
+
"""Interpolate attenuation coefficient from tabulated data."""
|
|
32
|
+
# Skip the header lines
|
|
33
|
+
energies, coeffs = np.loadtxt(filename, unpack=True, skiprows=12)
|
|
34
|
+
return np.interp(energy, energies, coeffs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_attenuation_coefficient(material, energy, file_path=None):
|
|
38
|
+
"""
|
|
39
|
+
Get attenuation coefficient for a given material and energy.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
material (str): 'water' or 'bone'
|
|
43
|
+
energy (float): Photon energy in keV
|
|
44
|
+
file_path (str, optional): Override default data file path
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
float: Linear attenuation coefficient in cm^-1
|
|
48
|
+
"""
|
|
49
|
+
density_water = 1.0 # g/cm^3
|
|
50
|
+
density_bone = 1.85 # g/cm^3 for cortical bone
|
|
51
|
+
|
|
52
|
+
if material == "water":
|
|
53
|
+
filename = data_path("h2o.atn")
|
|
54
|
+
elif material == "bone":
|
|
55
|
+
filename = data_path("bone.atn")
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError("Unknown material. Accepted values are 'water' or 'bone'.")
|
|
58
|
+
|
|
59
|
+
if file_path:
|
|
60
|
+
filepath = Path(file_path) / filename
|
|
61
|
+
else:
|
|
62
|
+
filepath = get_package_data_path(filename)
|
|
63
|
+
|
|
64
|
+
if not filepath.exists():
|
|
65
|
+
raise FileNotFoundError(f"Attenuation data file not found: {filepath}")
|
|
66
|
+
|
|
67
|
+
mass_attn_coeffs = interpolate_attenuation_coefficient(filepath, energy)
|
|
68
|
+
|
|
69
|
+
if material == "water":
|
|
70
|
+
return mass_attn_coeffs * density_water
|
|
71
|
+
elif material == "bone":
|
|
72
|
+
return mass_attn_coeffs * density_bone
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def hu_to_attenuation(image_array, photon_energy, file_path=None):
|
|
76
|
+
"""
|
|
77
|
+
Convert Hounsfield Units to attenuation coefficients.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
image_array (np.ndarray): Array of HU values
|
|
81
|
+
photon_energy (float): Photon energy in keV
|
|
82
|
+
file_path (str, optional): Override default data file path
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
np.ndarray: Attenuation map in cm^-1
|
|
86
|
+
"""
|
|
87
|
+
# Constants
|
|
88
|
+
HU_water = 0
|
|
89
|
+
HU_bone = 1000
|
|
90
|
+
|
|
91
|
+
# Convert photon_energy to MeV from keV
|
|
92
|
+
photon_energy_mev = photon_energy / 1000
|
|
93
|
+
|
|
94
|
+
# Get attenuation coefficients
|
|
95
|
+
mu_water = get_attenuation_coefficient("water", photon_energy_mev, file_path)
|
|
96
|
+
mu_bone = get_attenuation_coefficient("bone", photon_energy_mev, file_path)
|
|
97
|
+
|
|
98
|
+
# For air, we assume negligible attenuation
|
|
99
|
+
mu_air = 0.0
|
|
100
|
+
|
|
101
|
+
# Bilinear model slopes
|
|
102
|
+
slope_soft = (mu_water - mu_air) / (
|
|
103
|
+
HU_water - (-1000)
|
|
104
|
+
) # from -1000 HU (air) to 0 HU (water)
|
|
105
|
+
slope_bone = (mu_bone - mu_water) / (
|
|
106
|
+
HU_bone - HU_water
|
|
107
|
+
) # from 0 HU (water) to 1000 HU (bone)
|
|
108
|
+
|
|
109
|
+
# Compute attenuation map using the bilinear model
|
|
110
|
+
attenuation_map = np.where(
|
|
111
|
+
image_array <= HU_water,
|
|
112
|
+
mu_air + slope_soft * (image_array + 1000),
|
|
113
|
+
mu_water + slope_bone * (image_array - HU_water),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Ensure non-negative values
|
|
117
|
+
attenuation_map = np.maximum(attenuation_map, 0)
|
|
118
|
+
|
|
119
|
+
return attenuation_map
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def hu_to_density(image_array):
|
|
123
|
+
"""
|
|
124
|
+
Convert Hounsfield Units to density values.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
image_array (np.ndarray): Array of HU values
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
np.ndarray: Density map in g/cm^3
|
|
131
|
+
"""
|
|
132
|
+
# Constants
|
|
133
|
+
density_air = 0.001225 # g/cm^3
|
|
134
|
+
density_water = 1.0 # g/cm^3
|
|
135
|
+
density_bone = 1.85 # g/cm^3
|
|
136
|
+
|
|
137
|
+
HU_air = -1000
|
|
138
|
+
HU_water = 0
|
|
139
|
+
HU_bone = 1000
|
|
140
|
+
|
|
141
|
+
slope_soft = (density_water - density_air) / (HU_water - HU_air)
|
|
142
|
+
slope_bone = (density_bone - density_water) / (HU_bone - HU_water)
|
|
143
|
+
|
|
144
|
+
density_map = np.where(
|
|
145
|
+
image_array <= HU_water,
|
|
146
|
+
density_air + slope_soft * (image_array - HU_air),
|
|
147
|
+
density_water + slope_bone * (image_array - HU_water),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Ensure reasonable density bounds
|
|
151
|
+
density_map = np.clip(density_map, 0, 3.0) # Max density ~3 g/cm^3
|
|
152
|
+
|
|
153
|
+
return density_map
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def attenuation_to_density(attenuation_array, photon_energy, file_path=None):
|
|
157
|
+
"""
|
|
158
|
+
Convert attenuation coefficients to density values.
|
|
159
|
+
|
|
160
|
+
This is an approximate inverse of the attenuation calculation.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
attenuation_array (np.ndarray): Array of attenuation coefficients in cm^-1
|
|
164
|
+
photon_energy (float): Photon energy in keV
|
|
165
|
+
file_path (str, optional): Override default data file path
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
np.ndarray: Density map in g/cm^3
|
|
169
|
+
"""
|
|
170
|
+
# Convert photon_energy to MeV
|
|
171
|
+
photon_energy_mev = photon_energy / 1000
|
|
172
|
+
|
|
173
|
+
# Get attenuation coefficients
|
|
174
|
+
mu_water = get_attenuation_coefficient("water", photon_energy_mev, file_path)
|
|
175
|
+
mu_bone = get_attenuation_coefficient("bone", photon_energy_mev, file_path)
|
|
176
|
+
|
|
177
|
+
# Densities
|
|
178
|
+
# density_air = 0.001225 # g/cm^3 # Not used in this calculation
|
|
179
|
+
density_water = 1.0 # g/cm^3
|
|
180
|
+
density_bone = 1.85 # g/cm^3
|
|
181
|
+
|
|
182
|
+
# Bilinear model inverse
|
|
183
|
+
if mu_water > 0:
|
|
184
|
+
slope_soft = density_water / mu_water
|
|
185
|
+
else:
|
|
186
|
+
warnings.warn("Water attenuation coefficient is zero or negative")
|
|
187
|
+
slope_soft = 1.0
|
|
188
|
+
|
|
189
|
+
if mu_bone > mu_water:
|
|
190
|
+
slope_bone = (density_bone - density_water) / (mu_bone - mu_water)
|
|
191
|
+
else:
|
|
192
|
+
warnings.warn("Bone attenuation not greater than water")
|
|
193
|
+
slope_bone = 1.0
|
|
194
|
+
|
|
195
|
+
density_map = np.where(
|
|
196
|
+
attenuation_array <= mu_water,
|
|
197
|
+
slope_soft * attenuation_array,
|
|
198
|
+
density_water + slope_bone * (attenuation_array - mu_water),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Ensure reasonable bounds
|
|
202
|
+
density_map = np.clip(density_map, 0, 3.0)
|
|
203
|
+
|
|
204
|
+
return density_map
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def load_schneider_data():
|
|
208
|
+
"""
|
|
209
|
+
Load Schneider2000 tissue data from JSON file.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
dict: Schneider tissue data with HU ranges and densities
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
schneider_path = get_package_data_path("Schneider2000.json")
|
|
216
|
+
with open(schneider_path, "r") as f:
|
|
217
|
+
return json.load(f)
|
|
218
|
+
except FileNotFoundError:
|
|
219
|
+
raise FileNotFoundError(
|
|
220
|
+
"Schneider2000.json data file not found in package data"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def hu_to_density_schneider_piecewise(image_array):
|
|
225
|
+
"""
|
|
226
|
+
Convert HU to density using exact Schneider2000 piecewise segments.
|
|
227
|
+
|
|
228
|
+
Simple step function: any HU value between HU_lo and HU_hi gets the
|
|
229
|
+
exact density for that tissue segment.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
image_array (np.ndarray): Array of HU values
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
np.ndarray: Density map in g/cm^3
|
|
236
|
+
"""
|
|
237
|
+
schneider_data = load_schneider_data()
|
|
238
|
+
|
|
239
|
+
# Initialize output array with air density (default for unmapped values)
|
|
240
|
+
density_map = np.full_like(image_array, 0.001225, dtype=np.float32)
|
|
241
|
+
|
|
242
|
+
# Sort tissues by HU_lo for processing
|
|
243
|
+
tissues = sorted(schneider_data.items(), key=lambda x: x[1]["HU_lo (HU)"])
|
|
244
|
+
|
|
245
|
+
for tissue_name, tissue_data in tissues:
|
|
246
|
+
hu_lo = tissue_data["HU_lo (HU)"]
|
|
247
|
+
hu_hi = tissue_data["HU_hi (HU)"]
|
|
248
|
+
density_mg_cm3 = tissue_data["density (mg/cm3)"]
|
|
249
|
+
density_g_cm3 = density_mg_cm3 / 1000.0 # Convert to g/cm³
|
|
250
|
+
|
|
251
|
+
# Simple assignment: any HU in range gets this density
|
|
252
|
+
mask = (image_array >= hu_lo) & (image_array <= hu_hi)
|
|
253
|
+
density_map[mask] = density_g_cm3
|
|
254
|
+
|
|
255
|
+
return density_map
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def hu_to_density_schneider(image_array):
|
|
259
|
+
"""
|
|
260
|
+
Convert Hounsfield Units to density using Schneider2000 interpolated lookup table.
|
|
261
|
+
|
|
262
|
+
Uses the center point of each HU range for intelligent interpolation,
|
|
263
|
+
providing smooth transitions between tissue segments.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
image_array (np.ndarray): Array of HU values
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
np.ndarray: Density map in g/cm^3
|
|
270
|
+
"""
|
|
271
|
+
schneider_data = load_schneider_data()
|
|
272
|
+
|
|
273
|
+
# Create arrays for HU center points and densities
|
|
274
|
+
hu_centers = []
|
|
275
|
+
densities = []
|
|
276
|
+
|
|
277
|
+
# Sort tissues by HU_lo to ensure proper ordering
|
|
278
|
+
tissues = sorted(schneider_data.items(), key=lambda x: x[1]["HU_lo (HU)"])
|
|
279
|
+
|
|
280
|
+
for tissue_name, tissue_data in tissues:
|
|
281
|
+
hu_lo = tissue_data["HU_lo (HU)"]
|
|
282
|
+
hu_hi = tissue_data["HU_hi (HU)"]
|
|
283
|
+
density_mg_cm3 = tissue_data["density (mg/cm3)"]
|
|
284
|
+
density_g_cm3 = density_mg_cm3 / 1000.0 # Convert to g/cm³
|
|
285
|
+
|
|
286
|
+
# Use center point of HU range for interpolation
|
|
287
|
+
hu_center = (hu_lo + hu_hi) / 2.0
|
|
288
|
+
hu_centers.append(hu_center)
|
|
289
|
+
densities.append(density_g_cm3)
|
|
290
|
+
|
|
291
|
+
# Convert to numpy arrays
|
|
292
|
+
hu_points = np.array(hu_centers)
|
|
293
|
+
density_points = np.array(densities)
|
|
294
|
+
|
|
295
|
+
# Interpolate densities for input HU values
|
|
296
|
+
density_map = np.interp(image_array, hu_points, density_points)
|
|
297
|
+
|
|
298
|
+
# Ensure reasonable bounds (safety check)
|
|
299
|
+
density_map = np.clip(density_map, 0.001, 3.0)
|
|
300
|
+
|
|
301
|
+
return density_map
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def get_schneider_tissue_info(hu_value):
|
|
305
|
+
"""
|
|
306
|
+
Get tissue information for a specific HU value using Schneider data.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
hu_value (float): Hounsfield Unit value
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
dict: Tissue information including name, density, and HU range
|
|
313
|
+
|
|
314
|
+
Example:
|
|
315
|
+
>>> info = get_schneider_tissue_info(50)
|
|
316
|
+
>>> print(f"Tissue: {info['name']}, Density: {info['density_g_cm3']:.3f} g/cm³")
|
|
317
|
+
"""
|
|
318
|
+
schneider_data = load_schneider_data()
|
|
319
|
+
|
|
320
|
+
for tissue_name, tissue_data in schneider_data.items():
|
|
321
|
+
hu_lo = tissue_data["HU_lo (HU)"]
|
|
322
|
+
hu_hi = tissue_data["HU_hi (HU)"]
|
|
323
|
+
|
|
324
|
+
if hu_lo <= hu_value < hu_hi or (
|
|
325
|
+
hu_value == hu_hi and tissue_name.startswith("MetallImplants_43")
|
|
326
|
+
):
|
|
327
|
+
return {
|
|
328
|
+
"name": tissue_name,
|
|
329
|
+
"density_mg_cm3": tissue_data["density (mg/cm3)"],
|
|
330
|
+
"density_g_cm3": tissue_data["density (mg/cm3)"] / 1000.0,
|
|
331
|
+
"hu_range": (hu_lo, hu_hi),
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
# If not found, return None
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def compare_density_methods(image_array):
|
|
339
|
+
"""
|
|
340
|
+
Compare density conversion results between bilinear and Schneider methods.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
image_array (np.ndarray): Array of HU values
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
dict: Comparison results including density maps and statistics
|
|
347
|
+
"""
|
|
348
|
+
# Compute densities using different methods
|
|
349
|
+
density_bilinear = hu_to_density(image_array)
|
|
350
|
+
density_schneider_interp = hu_to_density_schneider(image_array)
|
|
351
|
+
density_schneider_piecewise = hu_to_density_schneider_piecewise(image_array)
|
|
352
|
+
|
|
353
|
+
# Compute differences
|
|
354
|
+
diff_interp = density_schneider_interp - density_bilinear
|
|
355
|
+
diff_piecewise = density_schneider_piecewise - density_bilinear
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
"bilinear": density_bilinear,
|
|
359
|
+
"schneider_interpolated": density_schneider_interp,
|
|
360
|
+
"schneider_piecewise": density_schneider_piecewise,
|
|
361
|
+
"difference_interpolated": diff_interp,
|
|
362
|
+
"difference_piecewise": diff_piecewise,
|
|
363
|
+
"max_diff_interp": np.max(np.abs(diff_interp)),
|
|
364
|
+
"max_diff_piecewise": np.max(np.abs(diff_piecewise)),
|
|
365
|
+
"mean_diff_interp": np.mean(np.abs(diff_interp)),
|
|
366
|
+
"mean_diff_piecewise": np.mean(np.abs(diff_piecewise)),
|
|
367
|
+
}
|