nifti_dynamic 0.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.
- nifti_dynamic-0.1.0/PKG-INFO +35 -0
- nifti_dynamic-0.1.0/README.md +15 -0
- nifti_dynamic-0.1.0/pyproject.toml +31 -0
- nifti_dynamic-0.1.0/setup.cfg +4 -0
- nifti_dynamic-0.1.0/src/nifti_dynamic/__init__.py +6 -0
- nifti_dynamic-0.1.0/src/nifti_dynamic/aorta_rois.py +339 -0
- nifti_dynamic-0.1.0/src/nifti_dynamic/conversion.py +34 -0
- nifti_dynamic-0.1.0/src/nifti_dynamic/patlak.py +81 -0
- nifti_dynamic-0.1.0/src/nifti_dynamic/utils.py +225 -0
- nifti_dynamic-0.1.0/src/nifti_dynamic/visualizations.py +162 -0
- nifti_dynamic-0.1.0/src/nifti_dynamic.egg-info/PKG-INFO +35 -0
- nifti_dynamic-0.1.0/src/nifti_dynamic.egg-info/SOURCES.txt +13 -0
- nifti_dynamic-0.1.0/src/nifti_dynamic.egg-info/dependency_links.txt +1 -0
- nifti_dynamic-0.1.0/src/nifti_dynamic.egg-info/requires.txt +10 -0
- nifti_dynamic-0.1.0/src/nifti_dynamic.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nifti_dynamic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A package for dynamic NIFTI analysis
|
|
5
|
+
Classifier: Programming Language :: Python :: 3
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: numpy
|
|
11
|
+
Requires-Dist: nibabel
|
|
12
|
+
Requires-Dist: indexed_gzip
|
|
13
|
+
Requires-Dist: scipy
|
|
14
|
+
Requires-Dist: scikit-learn
|
|
15
|
+
Requires-Dist: tqdm
|
|
16
|
+
Requires-Dist: ipython>=8.12.3
|
|
17
|
+
Requires-Dist: scikit-image>=0.21.0
|
|
18
|
+
Requires-Dist: matplotlib>=3.7.5
|
|
19
|
+
Requires-Dist: pandas>=2.0.3
|
|
20
|
+
|
|
21
|
+
# nifti_dynamic
|
|
22
|
+
|
|
23
|
+
A Python package for dynamic NIFTI analysis.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install nifti_dynamic
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
See example.py
|
|
34
|
+
|
|
35
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nifti_dynamic"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A package for dynamic NIFTI analysis"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"numpy",
|
|
13
|
+
"nibabel",
|
|
14
|
+
"indexed_gzip",
|
|
15
|
+
"scipy",
|
|
16
|
+
"scikit-learn",
|
|
17
|
+
"tqdm",
|
|
18
|
+
"ipython>=8.12.3",
|
|
19
|
+
"scikit-image>=0.21.0",
|
|
20
|
+
"matplotlib>=3.7.5",
|
|
21
|
+
"pandas>=2.0.3",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
package-dir = {"" = "src"}
|
|
31
|
+
packages = ["nifti_dynamic"]
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""
|
|
3
|
+
Script for extracting volumes of interest (VOIs) from different aorta segments
|
|
4
|
+
using a pre-segmented aorta mask
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import nibabel as nib
|
|
9
|
+
from scipy.ndimage import (
|
|
10
|
+
center_of_mass,
|
|
11
|
+
label,
|
|
12
|
+
uniform_filter,
|
|
13
|
+
binary_dilation,
|
|
14
|
+
median_filter
|
|
15
|
+
)
|
|
16
|
+
from skimage.measure import regionprops
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from nifti_dynamic.utils import img_to_array_or_dataobj
|
|
19
|
+
from nifti_dynamic.visualizations import plot_aorta_visualizations
|
|
20
|
+
from enum import Enum
|
|
21
|
+
|
|
22
|
+
class AortaSegment(Enum):
|
|
23
|
+
ASCENDING = 1
|
|
24
|
+
TOP = 2
|
|
25
|
+
DESCENDING = 3
|
|
26
|
+
DESCENDING_BOTTOM = 4
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def count_connected_components(slice_2d):
|
|
30
|
+
"""Count connected components in a 2D slice"""
|
|
31
|
+
labeled, num = label(slice_2d)
|
|
32
|
+
return num
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def count_axial_components(volume):
|
|
36
|
+
"""Count connected components along z-axis for each slice"""
|
|
37
|
+
return np.array([count_connected_components(volume[..., sl]) for sl in range(volume.shape[-1])])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_pattern_transition(array, pattern):
|
|
41
|
+
"""
|
|
42
|
+
Find index where pattern matches in array
|
|
43
|
+
Returns the middle point of the transition
|
|
44
|
+
"""
|
|
45
|
+
indices = np.where(np.all(np.lib.stride_tricks.sliding_window_view(array, len(pattern)) == pattern, axis=1))[0]
|
|
46
|
+
if len(indices) != 1:
|
|
47
|
+
raise ValueError(f"Expected 1 match, found {len(indices)}")
|
|
48
|
+
|
|
49
|
+
return indices[0] + len(pattern) // 2
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def find_aortic_segments_boundaries(aorta_volume):
|
|
53
|
+
"""
|
|
54
|
+
Identify axial indices where aorta transitions between segments
|
|
55
|
+
Returns the start and curve indices
|
|
56
|
+
"""
|
|
57
|
+
# Calculate transitions between different aortic segments
|
|
58
|
+
islands = count_axial_components(aorta_volume)
|
|
59
|
+
islands = np.minimum(islands, 2) # Cap max islands at 2
|
|
60
|
+
islands = median_filter(islands, size=5) # Smooth counts
|
|
61
|
+
|
|
62
|
+
# Find aortic transition points 1 -> 2 islands and 2->1 islands
|
|
63
|
+
ix_start = find_pattern_transition(islands, np.array([1, 1, 1, 1, 2, 2,2,2]))
|
|
64
|
+
ix_curve = find_pattern_transition(islands, np.array([2,2, 2, 2, 1, 1,1,1]))
|
|
65
|
+
|
|
66
|
+
return ix_start, ix_curve
|
|
67
|
+
|
|
68
|
+
def maybe_fix_floaters(aorta):
|
|
69
|
+
print("WARNING: Aorta is discontinuous, discarding smallest islands. Please inspect visually")
|
|
70
|
+
labeled, _ = label(aorta)
|
|
71
|
+
if len(np.unique(labeled)) > 2:
|
|
72
|
+
frequencies = np.bincount(labeled.flatten())
|
|
73
|
+
#ignore background counts (ix=0)
|
|
74
|
+
ix_largest_island = np.argmax(frequencies[1:])+1
|
|
75
|
+
labeled = labeled==ix_largest_island
|
|
76
|
+
return labeled
|
|
77
|
+
|
|
78
|
+
def segment_aorta(aorta):
|
|
79
|
+
# Segment aorta into four anatomical regions
|
|
80
|
+
aorta = aorta.copy()
|
|
81
|
+
aorta = maybe_fix_floaters(aorta)
|
|
82
|
+
|
|
83
|
+
ix_start, ix_curve = find_aortic_segments_boundaries(aorta)
|
|
84
|
+
|
|
85
|
+
# Process pre-curve region
|
|
86
|
+
aorta_seg = aorta.copy().astype(np.int64)
|
|
87
|
+
aorta_seg[..., ix_curve:] = 0
|
|
88
|
+
|
|
89
|
+
# Label components and determine ascending/descending parts
|
|
90
|
+
labeled, _ = label(aorta_seg)
|
|
91
|
+
|
|
92
|
+
# Determine which label is ascending vs descending based on volume
|
|
93
|
+
if np.sum(labeled == 1) > np.sum(labeled == 2):
|
|
94
|
+
mapping = np.array([0, AortaSegment.DESCENDING.value, AortaSegment.ASCENDING.value])
|
|
95
|
+
else:
|
|
96
|
+
mapping = np.array([0, AortaSegment.ASCENDING.value, AortaSegment.DESCENDING.value])
|
|
97
|
+
|
|
98
|
+
aorta_seg = mapping[labeled]
|
|
99
|
+
|
|
100
|
+
# Mark top section
|
|
101
|
+
aorta_seg[..., ix_curve:] = aorta[..., ix_curve:] * AortaSegment.TOP.value
|
|
102
|
+
|
|
103
|
+
# Mark descending bottom section
|
|
104
|
+
mask = (aorta_seg == AortaSegment.DESCENDING.value) & (np.arange(aorta.shape[-1]) < ix_start)[None, None, :]
|
|
105
|
+
aorta_seg[mask] = AortaSegment.DESCENDING_BOTTOM.value
|
|
106
|
+
|
|
107
|
+
return aorta_seg.astype(np.uint8)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def create_cylindrical_voi(aorta_segment, pet, voxel_size, volume_ml=1.0, cylinder_width=3):
|
|
111
|
+
"""
|
|
112
|
+
Create a cylindrical VOI inside the specified aorta segment
|
|
113
|
+
|
|
114
|
+
Parameters:
|
|
115
|
+
-----------
|
|
116
|
+
aorta_segment : numpy.ndarray
|
|
117
|
+
Binary mask of the aorta segment
|
|
118
|
+
pet : numpy.ndarray
|
|
119
|
+
PET image data
|
|
120
|
+
voxel_size : tuple or array-like
|
|
121
|
+
Voxel dimensions in mm
|
|
122
|
+
volume_ml : float
|
|
123
|
+
Target volume in milliliters (default: 1.0)
|
|
124
|
+
cylinder_width : int
|
|
125
|
+
Width of the cylindrical cross-section (default: 3)
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
--------
|
|
129
|
+
numpy.ndarray
|
|
130
|
+
Binary mask of the VOI
|
|
131
|
+
"""
|
|
132
|
+
# Calculate target volume in voxels
|
|
133
|
+
voxel_volume = np.prod(voxel_size)
|
|
134
|
+
target_voxels = int(volume_ml * 1000 / voxel_volume)
|
|
135
|
+
|
|
136
|
+
# Create empty VOI
|
|
137
|
+
voi = np.zeros_like(aorta_segment, dtype=bool)
|
|
138
|
+
|
|
139
|
+
# Calculate required slices for target volume
|
|
140
|
+
voxels_per_slice = cylinder_width**2
|
|
141
|
+
n_slices_needed = max(1, int(np.ceil(target_voxels / voxels_per_slice)))
|
|
142
|
+
|
|
143
|
+
# Find optimal placement based on PET uptake
|
|
144
|
+
pet_masked = np.ma.masked_array(data=pet, mask=~aorta_segment)
|
|
145
|
+
median_axial_uptake = np.ma.median(pet_masked, axis=(0,1)).filled(0)
|
|
146
|
+
median_axial_uptake = uniform_filter(median_axial_uptake, n_slices_needed)
|
|
147
|
+
|
|
148
|
+
#Ensure that the start_slice lies sufficiently in the middle of the aorta segment
|
|
149
|
+
aorta_axial_mask_ixs = np.where(np.any(aorta_segment,axis=(0,1)))[0]
|
|
150
|
+
axial_ix_min, axial_ix_max = np.min(aorta_axial_mask_ixs), np.max(aorta_axial_mask_ixs)
|
|
151
|
+
if axial_ix_max-axial_ix_min < n_slices_needed:
|
|
152
|
+
raise Exception(f"The requested cylinder is too long {n_slices_needed} for the aorta segment {axial_ix_max-axial_ix_min}. Either reduce the cylinder volume or increase the cylinder width")
|
|
153
|
+
median_axial_uptake[:axial_ix_min + n_slices_needed//2 ] = 0
|
|
154
|
+
median_axial_uptake[ axial_ix_max - n_slices_needed//2:] = 0
|
|
155
|
+
|
|
156
|
+
start_slice = np.argmax(median_axial_uptake) - n_slices_needed//2
|
|
157
|
+
|
|
158
|
+
# Place VOI seeds at center of mass for each slice
|
|
159
|
+
pet_masked = pet_masked.filled(0)
|
|
160
|
+
for slc in range(start_slice, start_slice+n_slices_needed):
|
|
161
|
+
x, y = center_of_mass(pet_masked[..., slc])
|
|
162
|
+
x, y = int(round(x)), int(round(y))
|
|
163
|
+
voi[x, y, slc] = True
|
|
164
|
+
|
|
165
|
+
# Create cylindrical shape by dilating the seed points
|
|
166
|
+
dilation_mask = np.ones((cylinder_width, cylinder_width, 1), dtype=bool)
|
|
167
|
+
voi = binary_dilation(voi, dilation_mask)
|
|
168
|
+
|
|
169
|
+
# Calculate actual volume achieved
|
|
170
|
+
actual_volume_ml = np.sum(voi) * voxel_volume / 1000
|
|
171
|
+
print(f"Created cylinder of length {n_slices_needed} with volume: {actual_volume_ml:.2f} ml (target: {volume_ml:.2f} ml)")
|
|
172
|
+
|
|
173
|
+
return voi
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def average_early_pet_frames(dpet, frame_times_start, t_threshold=40):
|
|
177
|
+
"""
|
|
178
|
+
Average PET frames up to the specified time threshold
|
|
179
|
+
|
|
180
|
+
Parameters:
|
|
181
|
+
-----------
|
|
182
|
+
dpet : nibabel.Nifti1Image
|
|
183
|
+
Dynamic PET image
|
|
184
|
+
frame_times_start : numpy.ndarray
|
|
185
|
+
Start times for each frame
|
|
186
|
+
t_threshold : int
|
|
187
|
+
Time threshold in seconds (default: 40)
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
--------
|
|
191
|
+
numpy.ndarray
|
|
192
|
+
Averaged early PET frames
|
|
193
|
+
"""
|
|
194
|
+
pet_arr = img_to_array_or_dataobj(dpet)
|
|
195
|
+
n_frames = np.sum(frame_times_start < t_threshold)
|
|
196
|
+
return pet_arr[..., :n_frames].mean(axis=-1)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def refine_aorta_with_pet_uptake(aorta, pet):
|
|
200
|
+
"""
|
|
201
|
+
Refine aorta segmentation based on PET uptake
|
|
202
|
+
Only keeps voxels with sufficient activity compared to median aorta uptake
|
|
203
|
+
"""
|
|
204
|
+
pet_median_aorta = np.median(pet[aorta > 0])
|
|
205
|
+
activity_mask = pet > (2/3 * pet_median_aorta)
|
|
206
|
+
aorta_refined = aorta.copy()
|
|
207
|
+
aorta_refined[~activity_mask] = 0
|
|
208
|
+
return aorta_refined
|
|
209
|
+
|
|
210
|
+
def extract_aorta_segments(aorta_mask, pet):
|
|
211
|
+
"""
|
|
212
|
+
Extract VOIs from different aorta segments
|
|
213
|
+
|
|
214
|
+
Parameters:
|
|
215
|
+
-----------
|
|
216
|
+
aorta_mask : numpy.ndarray
|
|
217
|
+
Binary mask of the aorta from totalsegmentator
|
|
218
|
+
affine : numpy.ndarray
|
|
219
|
+
Affine matrix of the aorta image
|
|
220
|
+
dpet : nibabel.Nifti1Image
|
|
221
|
+
Dynamic PET image
|
|
222
|
+
frame_times_start : numpy.ndarray
|
|
223
|
+
Start times for each frame
|
|
224
|
+
t_threshold : int
|
|
225
|
+
Time threshold for early frames in seconds (default: 40)
|
|
226
|
+
volume_ml : float
|
|
227
|
+
Target volume in milliliters (default: 1.0)
|
|
228
|
+
cylinder_width : int
|
|
229
|
+
Width of the cylindrical cross-section (default: 3)
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
--------
|
|
233
|
+
tuple
|
|
234
|
+
(VOIs mask, segmented aorta mask)
|
|
235
|
+
"""
|
|
236
|
+
affine = aorta_mask.affine
|
|
237
|
+
aorta_mask = aorta_mask.get_fdata()>0.5
|
|
238
|
+
aorta_segments = segment_aorta(aorta_mask)
|
|
239
|
+
aorta_segments = refine_aorta_with_pet_uptake(aorta_segments, pet)
|
|
240
|
+
|
|
241
|
+
return nib.Nifti1Image(aorta_segments.astype("int16"),affine)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def extract_aorta_vois(aorta_segments, pet, volume_ml=1.0, cylinder_width=3,segment=None):
|
|
245
|
+
"""
|
|
246
|
+
Extract VOIs from different aorta segments
|
|
247
|
+
|
|
248
|
+
Parameters:
|
|
249
|
+
-----------
|
|
250
|
+
aorta_mask : numpy.ndarray
|
|
251
|
+
Binary mask of the aorta from totalsegmentator
|
|
252
|
+
affine : numpy.ndarray
|
|
253
|
+
Affine matrix of the aorta image
|
|
254
|
+
dpet : nibabel.Nifti1Image
|
|
255
|
+
Dynamic PET image
|
|
256
|
+
frame_times_start : numpy.ndarray
|
|
257
|
+
Start times for each frame
|
|
258
|
+
t_threshold : int
|
|
259
|
+
Time threshold for early frames in seconds (default: 40)
|
|
260
|
+
volume_ml : float
|
|
261
|
+
Target volume in milliliters (default: 1.0)
|
|
262
|
+
cylinder_width : int
|
|
263
|
+
Width of the cylindrical cross-section (default: 3)
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
--------
|
|
267
|
+
tuple
|
|
268
|
+
(VOIs mask, segmented aorta mask)
|
|
269
|
+
"""
|
|
270
|
+
affine = aorta_segments.affine
|
|
271
|
+
# Calculate voxel size from affine matrix
|
|
272
|
+
voxel_size = np.array(aorta_segments.header.get_zooms())
|
|
273
|
+
|
|
274
|
+
aorta_segments_arr = aorta_segments.get_fdata().astype(np.int16)
|
|
275
|
+
# Initialize VOIs mask
|
|
276
|
+
vois = np.zeros_like(aorta_segments_arr)
|
|
277
|
+
|
|
278
|
+
# Do all Aorta Segments by default
|
|
279
|
+
if segment is None:
|
|
280
|
+
segs = AortaSegment
|
|
281
|
+
else:
|
|
282
|
+
segs = [segment]
|
|
283
|
+
|
|
284
|
+
# Create VOIs for each aorta segment
|
|
285
|
+
for seg in segs:
|
|
286
|
+
aorta_segment = aorta_segments_arr == seg.value
|
|
287
|
+
print("Extracting VOI for", seg.name)
|
|
288
|
+
if seg == AortaSegment.TOP:
|
|
289
|
+
voi = create_cylindrical_voi(aorta_segment.swapaxes(1,2), pet.swapaxes(1,2), voxel_size=voxel_size[[0,2,1]],
|
|
290
|
+
volume_ml=volume_ml, cylinder_width=cylinder_width)
|
|
291
|
+
voi = voi.swapaxes(1,2)
|
|
292
|
+
else:
|
|
293
|
+
voi = create_cylindrical_voi(aorta_segment, pet, voxel_size=voxel_size,
|
|
294
|
+
volume_ml=volume_ml, cylinder_width=cylinder_width)
|
|
295
|
+
vois[voi] = seg.value
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
vois = nib.Nifti1Image(vois,affine)
|
|
299
|
+
return vois
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def pipeline(aorta_mask, dpet, frame_times_start, t_threshold=40, volume_ml=1.0, cylinder_width=3, segment=None, image_path=None):
|
|
303
|
+
"""
|
|
304
|
+
Extract VOIs from different aorta segments
|
|
305
|
+
|
|
306
|
+
Parameters:
|
|
307
|
+
-----------
|
|
308
|
+
aorta_mask : numpy.ndarray
|
|
309
|
+
Binary mask of the aorta from totalsegmentator
|
|
310
|
+
affine : numpy.ndarray
|
|
311
|
+
Affine matrix of the aorta image
|
|
312
|
+
dpet : nibabel.Nifti1Image
|
|
313
|
+
Dynamic PET image
|
|
314
|
+
frame_times_start : numpy.ndarray
|
|
315
|
+
Start times for each frame
|
|
316
|
+
t_threshold : int
|
|
317
|
+
Time threshold for early frames in seconds (default: 40)
|
|
318
|
+
volume_ml : float
|
|
319
|
+
Target volume in milliliters (default: 1.0)
|
|
320
|
+
cylinder_width : int
|
|
321
|
+
Width of the cylindrical cross-section (default: 3)
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
--------
|
|
325
|
+
tuple
|
|
326
|
+
(VOIs mask, segmented aorta mask)
|
|
327
|
+
"""
|
|
328
|
+
pet = average_early_pet_frames(dpet, frame_times_start, t_threshold)
|
|
329
|
+
aorta_segments = extract_aorta_segments(aorta_mask,pet)
|
|
330
|
+
aorta_vois = extract_aorta_vois(aorta_segments,pet,volume_ml=volume_ml,cylinder_width=cylinder_width,segment=segment)
|
|
331
|
+
|
|
332
|
+
if image_path is not None:
|
|
333
|
+
print("Creating aorta visualization")
|
|
334
|
+
plot_aorta_visualizations(
|
|
335
|
+
pet,
|
|
336
|
+
aorta_segments,
|
|
337
|
+
aorta_vois,
|
|
338
|
+
image_path)
|
|
339
|
+
return aorta_segments, aorta_vois
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
import shutil
|
|
6
|
+
|
|
7
|
+
_default_dcm2niix_args = ["-m","y","-z","o","-b","y", "-ba","n"]
|
|
8
|
+
|
|
9
|
+
def convert_dicom_to_nifti(dcmdir, nifti_out,sidecar_out=None):
|
|
10
|
+
assert str(nifti_out).endswith(".nii.gz"), "Output filename must be a nifti gz file e.g. (out.nii.gz)"
|
|
11
|
+
if sidecar_out is None:
|
|
12
|
+
sidecar_out = nifti_out.replace(".nii.gz",".json")
|
|
13
|
+
|
|
14
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
15
|
+
cmd = ["dcm2niix","-o", tmp] + _default_dcm2niix_args + [str(dcmdir)]
|
|
16
|
+
subprocess.check_output(cmd)
|
|
17
|
+
nifti_tmp = _get_nifti_from_dir(tmp)
|
|
18
|
+
sidecar_tmp = _get_json_from_dir(tmp)
|
|
19
|
+
|
|
20
|
+
shutil.move(nifti_tmp,nifti_out)
|
|
21
|
+
shutil.move(sidecar_tmp,sidecar_out)
|
|
22
|
+
|
|
23
|
+
def _get_nifti_from_dir(tempdir):
|
|
24
|
+
fs = os.listdir(tempdir)
|
|
25
|
+
fs = [x for x in fs if x.endswith(".nii.gz") and "ROI" not in x]
|
|
26
|
+
assert len(fs) == 1, "Too many or few output files; " + ",".join(fs)
|
|
27
|
+
return os.path.join(tempdir,fs[0])
|
|
28
|
+
|
|
29
|
+
def _get_json_from_dir(tempdir):
|
|
30
|
+
fs = os.listdir(tempdir)
|
|
31
|
+
fs = [x for x in fs if x.endswith(".json") and "ROI" not in x]
|
|
32
|
+
assert len(fs) == 1, "Too many or few output files; " + ",".join(fs)
|
|
33
|
+
return os.path.join(tempdir,fs[0])
|
|
34
|
+
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from scipy.ndimage import gaussian_filter
|
|
2
|
+
import numpy as np
|
|
3
|
+
from tqdm import tqdm
|
|
4
|
+
from scipy.integrate import cumulative_simpson
|
|
5
|
+
from sklearn.linear_model import LinearRegression
|
|
6
|
+
from .utils import OverlappedChunkIterator, img_to_array_or_dataobj
|
|
7
|
+
|
|
8
|
+
def roi_patlak(roi_tac,if_tac,t,n_frames_linear_regression):
|
|
9
|
+
slopes, intercepts = _voxel_patlak_chunk(roi_tac,if_tac,t,n_frames_linear_regression=n_frames_linear_regression)
|
|
10
|
+
|
|
11
|
+
with np.errstate(divide='ignore',invalid='ignore'):
|
|
12
|
+
_X = cumulative_simpson(if_tac,x=t/60,initial=0) / if_tac
|
|
13
|
+
X = _X.reshape(-1,1)
|
|
14
|
+
|
|
15
|
+
# Normalized voxel response
|
|
16
|
+
Y = roi_tac.reshape(-1, roi_tac.shape[-1]).T[:]
|
|
17
|
+
Y = Y/if_tac[:,None]
|
|
18
|
+
|
|
19
|
+
return slopes, intercepts, X, Y
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _voxel_patlak_chunk(arr,input_fun,t,n_frames_linear_regression=10):
|
|
23
|
+
|
|
24
|
+
# Normalized cumsum AIF
|
|
25
|
+
with np.errstate(divide='ignore',invalid='ignore'):
|
|
26
|
+
_X = cumulative_simpson(input_fun,x=t/60,initial=0) / input_fun
|
|
27
|
+
X = _X[-n_frames_linear_regression:].reshape(-1,1)
|
|
28
|
+
|
|
29
|
+
# Normalized voxel response
|
|
30
|
+
Y = arr.reshape(-1, arr.shape[-1]).T[-n_frames_linear_regression:]
|
|
31
|
+
Y = Y/input_fun[-n_frames_linear_regression:,None]
|
|
32
|
+
|
|
33
|
+
#Linear regression
|
|
34
|
+
reg = LinearRegression().fit(X, Y)
|
|
35
|
+
slopes = reg.coef_.reshape(arr.shape[:-1])
|
|
36
|
+
intercepts = reg.intercept_.reshape(arr.shape[:-1])
|
|
37
|
+
|
|
38
|
+
return slopes, intercepts
|
|
39
|
+
|
|
40
|
+
def voxel_patlak(img, input_fun, t, gaussian_filter_size=0, n_frames_linear_regression=10, axial_chunk_size=8):
|
|
41
|
+
"""
|
|
42
|
+
Process image data in overlapping chunks, applying Gaussian smoothing and keeping only valid center portions.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
img: Input 4D image array
|
|
46
|
+
input_fun: Input function for Patlak analysis
|
|
47
|
+
t: Time points
|
|
48
|
+
gaussian_std: Standard deviation for Gaussian smoothing (default: 0)
|
|
49
|
+
n_frames_linear_regression: Number of frames for linear regression (default: 10)
|
|
50
|
+
axial_chunk_size: Size of axial chunks to process (default: 8)
|
|
51
|
+
"""
|
|
52
|
+
img = img_to_array_or_dataobj(img)
|
|
53
|
+
out = np.zeros(img.shape[:-1])
|
|
54
|
+
out_intercepts = np.zeros(img.shape[:-1])
|
|
55
|
+
border_size = 3 * gaussian_filter_size if gaussian_filter_size > 0 else 0
|
|
56
|
+
|
|
57
|
+
# Create iterator for overlapped chunks
|
|
58
|
+
chunk_iterator = OverlappedChunkIterator(
|
|
59
|
+
array_size=img.shape[-2],
|
|
60
|
+
chunk_size=axial_chunk_size,
|
|
61
|
+
border_size=border_size
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Process each chunk
|
|
65
|
+
for start_idx, end_idx, valid_start, valid_end, out_start, out_size in tqdm(chunk_iterator):
|
|
66
|
+
# Extract and process chunk
|
|
67
|
+
chunk = img[..., start_idx:end_idx, -n_frames_linear_regression:]
|
|
68
|
+
|
|
69
|
+
# Apply Gaussian filter if needed
|
|
70
|
+
if gaussian_filter_size > 0:
|
|
71
|
+
chunk = gaussian_filter(chunk, sigma=[gaussian_filter_size, gaussian_filter_size, gaussian_filter_size, 0])
|
|
72
|
+
|
|
73
|
+
# Process only the valid portion
|
|
74
|
+
valid_chunk = chunk[..., valid_start:valid_end, :]
|
|
75
|
+
slopes, intercepts = _voxel_patlak_chunk(valid_chunk, input_fun, t, n_frames_linear_regression)
|
|
76
|
+
|
|
77
|
+
# Store results
|
|
78
|
+
out[..., out_start:out_start + out_size] = slopes
|
|
79
|
+
out_intercepts[..., out_start:out_start + out_size] = intercepts
|
|
80
|
+
|
|
81
|
+
return out, out_intercepts
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import nibabel as nib
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from tqdm import tqdm
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import os
|
|
7
|
+
import csv
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OverlappedChunkIterator:
|
|
11
|
+
"""
|
|
12
|
+
Iterator for processing array data in overlapping chunks with border handling.
|
|
13
|
+
Useful for operations that have edge effects (like Gaussian filtering).
|
|
14
|
+
"""
|
|
15
|
+
def __init__(self, array_size, chunk_size, border_size):
|
|
16
|
+
"""
|
|
17
|
+
Initialize the iterator.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
array_size: Size of the array to be chunked
|
|
21
|
+
chunk_size: Size of each chunk to process
|
|
22
|
+
border_size: Size of the border to overlap (e.g., 3 * gaussian_std)
|
|
23
|
+
"""
|
|
24
|
+
self.array_size = array_size
|
|
25
|
+
self.chunk_size = chunk_size
|
|
26
|
+
self.border_size = border_size
|
|
27
|
+
self.effective_chunk_size = chunk_size - 2 * border_size
|
|
28
|
+
|
|
29
|
+
if self.effective_chunk_size <= 0:
|
|
30
|
+
raise ValueError("Chunk size too small for given border size. "
|
|
31
|
+
"Increase chunk_size or decrease border_size.")
|
|
32
|
+
|
|
33
|
+
def __len__(self):
|
|
34
|
+
"""
|
|
35
|
+
Calculate total number of chunks that will be processed.
|
|
36
|
+
"""
|
|
37
|
+
return (self.array_size + self.effective_chunk_size - 1) // self.effective_chunk_size
|
|
38
|
+
|
|
39
|
+
def __iter__(self):
|
|
40
|
+
"""
|
|
41
|
+
Returns iterator object (self).
|
|
42
|
+
"""
|
|
43
|
+
self.current_pos = 0
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def __next__(self):
|
|
47
|
+
"""
|
|
48
|
+
Returns the next chunk information as a tuple:
|
|
49
|
+
(start_index, end_index, valid_start, valid_end, output_start, output_size)
|
|
50
|
+
"""
|
|
51
|
+
if self.current_pos >= self.array_size:
|
|
52
|
+
raise StopIteration
|
|
53
|
+
|
|
54
|
+
# Calculate padding sizes
|
|
55
|
+
pad_before = min(self.border_size, self.current_pos)
|
|
56
|
+
remaining_space = self.array_size - (self.current_pos + self.effective_chunk_size)
|
|
57
|
+
pad_after = min(self.border_size, max(0, remaining_space))
|
|
58
|
+
|
|
59
|
+
# Calculate chunk indices
|
|
60
|
+
start_idx = self.current_pos - pad_before
|
|
61
|
+
end_idx = self.current_pos + self.effective_chunk_size + pad_after
|
|
62
|
+
|
|
63
|
+
# Calculate valid region within chunk
|
|
64
|
+
valid_start = pad_before
|
|
65
|
+
valid_end = (end_idx - start_idx) - pad_after
|
|
66
|
+
|
|
67
|
+
# Calculate output region
|
|
68
|
+
output_start = self.current_pos
|
|
69
|
+
output_size = min(self.effective_chunk_size, self.array_size - self.current_pos)
|
|
70
|
+
|
|
71
|
+
# Prepare for next iteration
|
|
72
|
+
self.current_pos += self.effective_chunk_size
|
|
73
|
+
|
|
74
|
+
return (start_idx, end_idx, valid_start, valid_end, output_start, output_size)
|
|
75
|
+
|
|
76
|
+
def img_to_array_or_dataobj(img):
|
|
77
|
+
if isinstance(img, nib.nifti1.Nifti1Image):
|
|
78
|
+
return img.dataobj
|
|
79
|
+
elif isinstance(img, np.ndarray):
|
|
80
|
+
return img
|
|
81
|
+
elif isinstance(img,nib.arrayproxy.ArrayProxy):
|
|
82
|
+
return img
|
|
83
|
+
elif isinstance(img,Path) or isinstance(img,str):
|
|
84
|
+
return nib.load(img).dataobj
|
|
85
|
+
else:
|
|
86
|
+
raise ValueError("Input must be a Nifti1Image or a numpy array.")
|
|
87
|
+
|
|
88
|
+
# def extract_tac(img, seg, max_roi_size=None):
|
|
89
|
+
# img = img_to_array_or_dataobj(img)
|
|
90
|
+
# seg = seg > 0
|
|
91
|
+
# nonzero = np.nonzero(seg)
|
|
92
|
+
# # Get min and max for each dimension
|
|
93
|
+
# xmin, xmax = np.min(nonzero[0]), np.max(nonzero[0])
|
|
94
|
+
# ymin, ymax = np.min(nonzero[1]), np.max(nonzero[1])
|
|
95
|
+
# zmin, zmax = np.min(nonzero[2]), np.max(nonzero[2])
|
|
96
|
+
|
|
97
|
+
# if max_roi_size is not None and (xmax-xmin)*(ymax-ymin)*(zmax-zmin) > max_roi_size:
|
|
98
|
+
# raise ValueError("Segmentation too big, use extract_multiple:tacs")
|
|
99
|
+
|
|
100
|
+
# seg_bb = img[xmin:xmax+1, ymin:ymax+1, zmin:zmax+1,:]
|
|
101
|
+
# tac = seg_bb[seg[xmin:xmax+1, ymin:ymax+1, zmin:zmax+1],:].mean(axis=0)
|
|
102
|
+
|
|
103
|
+
# return tac
|
|
104
|
+
|
|
105
|
+
def extract_tac(img, seg, max_roi_size=None, return_std_n=False):
|
|
106
|
+
img = img_to_array_or_dataobj(img)
|
|
107
|
+
seg = seg > 0
|
|
108
|
+
nonzero = np.nonzero(seg)
|
|
109
|
+
# Get min and max for each dimension
|
|
110
|
+
xmin, xmax = np.min(nonzero[0]), np.max(nonzero[0])
|
|
111
|
+
ymin, ymax = np.min(nonzero[1]), np.max(nonzero[1])
|
|
112
|
+
zmin, zmax = np.min(nonzero[2]), np.max(nonzero[2])
|
|
113
|
+
|
|
114
|
+
## Vectorized operations can use a lot of memory.
|
|
115
|
+
if max_roi_size is not None and (xmax-xmin)*(ymax-ymin)*(zmax-zmin)*img.shape[-1] > max_roi_size:
|
|
116
|
+
raise ValueError("Segmentation too big, use extract_multiple_tacs")
|
|
117
|
+
|
|
118
|
+
img_bb = img[xmin:xmax+1, ymin:ymax+1, zmin:zmax+1,:]
|
|
119
|
+
img_masked = img_bb[seg[xmin:xmax+1, ymin:ymax+1, zmin:zmax+1],:]
|
|
120
|
+
tac_mean = img_masked.mean(axis=0)
|
|
121
|
+
|
|
122
|
+
if return_std_n:
|
|
123
|
+
tac_std = img_masked.std(axis=0)
|
|
124
|
+
n_voxels = np.array([seg.sum()]*len(tac_mean))
|
|
125
|
+
return tac_mean, tac_std, n_voxels
|
|
126
|
+
else:
|
|
127
|
+
return tac_mean
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def extract_multiple_tacs(img, seg, max_roi_size_factor=2, return_std_n=False):
|
|
132
|
+
img = img_to_array_or_dataobj(img)
|
|
133
|
+
|
|
134
|
+
#handle static images
|
|
135
|
+
if img.ndim == 3:
|
|
136
|
+
img = np.asanyarray(img)
|
|
137
|
+
img = img[:,:,:,np.newaxis]
|
|
138
|
+
|
|
139
|
+
n_frames = img.shape[-1]
|
|
140
|
+
|
|
141
|
+
targets = list(np.unique(seg))
|
|
142
|
+
if 0 in targets:
|
|
143
|
+
targets.remove(0)
|
|
144
|
+
|
|
145
|
+
tacs_mean = {int(x):[] for x in targets}
|
|
146
|
+
tacs_std = {int(x):[] for x in targets}
|
|
147
|
+
tacs_n = {int(x):[] for x in targets}
|
|
148
|
+
|
|
149
|
+
#Try 4D cropping - faster but uses too much memory for larger organs
|
|
150
|
+
|
|
151
|
+
max_roi_size = max_roi_size_factor*np.prod(seg.shape)
|
|
152
|
+
for k in tqdm(tacs_mean):
|
|
153
|
+
try:
|
|
154
|
+
tacs_mean[k], tacs_std[k], tacs_n[k] = extract_tac(img,seg==k,max_roi_size=max_roi_size,return_std_n=True)
|
|
155
|
+
targets.remove(k)
|
|
156
|
+
except ValueError as e:
|
|
157
|
+
print("label",k,"too large - will run without 4D cropping")
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
#Iterate through each frame - slower but uses less memory
|
|
161
|
+
if len(targets) > 0:
|
|
162
|
+
for i in tqdm(range(n_frames)):
|
|
163
|
+
frame = img[...,i]
|
|
164
|
+
for k in targets:
|
|
165
|
+
seg_target = seg==k
|
|
166
|
+
arr = frame[seg_target]
|
|
167
|
+
tacs_mean[k].append(arr.mean())
|
|
168
|
+
tacs_std[k].append(arr.mean())
|
|
169
|
+
tacs_n[k].append(seg_target.sum())
|
|
170
|
+
|
|
171
|
+
for k in targets:
|
|
172
|
+
tacs_mean[k] = np.array(tacs_mean[k])
|
|
173
|
+
tacs_std[k] = np.array(tacs_std[k])
|
|
174
|
+
tacs_n[k] = np.array(tacs_n[k])
|
|
175
|
+
|
|
176
|
+
if return_std_n:
|
|
177
|
+
return tacs_mean, tacs_std, tacs_n
|
|
178
|
+
else:
|
|
179
|
+
return tacs_mean
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def save_tac(filename, tac_mean,tac_std = None, n_voxels = None):
|
|
183
|
+
filename = Path(filename)
|
|
184
|
+
os.makedirs(filename.parent,exist_ok=True)
|
|
185
|
+
data = {
|
|
186
|
+
"mu": [float(x) for x in tac_mean],
|
|
187
|
+
"std": [float(x) if tac_std is not None else None for x in tac_std],
|
|
188
|
+
"n": [int(x) if n_voxels is not None else None for x in n_voxels],
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
with open(filename, 'w', newline='') as f:
|
|
192
|
+
writer = csv.writer(f)
|
|
193
|
+
writer.writerow(data.keys()) # Write headers
|
|
194
|
+
writer.writerows(zip(*data.values())) # Write data rows
|
|
195
|
+
|
|
196
|
+
def load_tac(filename):
|
|
197
|
+
with open(filename, 'r', newline='') as f:
|
|
198
|
+
reader = csv.reader(f)
|
|
199
|
+
headers = next(reader) # Read header row
|
|
200
|
+
read_dict = {header: list(column) for header, column in zip(headers, zip(*reader))}
|
|
201
|
+
return np.array(read_dict["mu"]).astype(float), np.array(read_dict["std"]).astype(float), np.array(read_dict["n"]).astype(int)
|
|
202
|
+
|
|
203
|
+
def _pooled_mean_variance(mu1,mu2,n1,n2,v1,v2):
|
|
204
|
+
n_comb = n1+n2
|
|
205
|
+
mu_comb = (mu1*n1+mu2*n2)/(n_comb)
|
|
206
|
+
var_comb = (n1*v1+n2*v2)/n_comb+n1*n2*np.square((mu1-mu2)/n_comb)
|
|
207
|
+
return np.nan_to_num(mu_comb), np.nan_to_num(var_comb), n_comb
|
|
208
|
+
|
|
209
|
+
def combine_tacs(tacs_paths, tacs_output_path):
|
|
210
|
+
comb_mu = comb_var = comb_n = 0
|
|
211
|
+
|
|
212
|
+
for tac_p in tacs_paths:
|
|
213
|
+
mu, std, n = load_tac(tac_p)
|
|
214
|
+
comb_mu, comb_var, comb_n = _pooled_mean_variance(mu,comb_mu,n,comb_n,np.square(std),comb_var)
|
|
215
|
+
|
|
216
|
+
save_tac(tacs_output_path,comb_mu,np.sqrt(comb_var),comb_n)
|
|
217
|
+
|
|
218
|
+
def load_and_combine_tacs(tacs_paths):
|
|
219
|
+
comb_mu = comb_var = comb_n = 0
|
|
220
|
+
|
|
221
|
+
for tac_p in tacs_paths:
|
|
222
|
+
mu, std, n = load_tac(tac_p)
|
|
223
|
+
comb_mu, comb_var, comb_n = _pooled_mean_variance(mu,comb_mu,n,comb_n,np.square(std),comb_var)
|
|
224
|
+
|
|
225
|
+
return comb_mu,np.sqrt(comb_var),comb_n
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import nibabel as nib
|
|
2
|
+
import numpy as np
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from matplotlib import pyplot as plt, colors
|
|
5
|
+
from PIL import Image
|
|
6
|
+
import tempfile
|
|
7
|
+
import cv2
|
|
8
|
+
from scipy.ndimage import median_filter
|
|
9
|
+
|
|
10
|
+
_segmentation_cmap = colors.ListedColormap([
|
|
11
|
+
plt.cm.tab10(1),
|
|
12
|
+
plt.cm.tab10(0.99),
|
|
13
|
+
plt.cm.Set2(5/7),
|
|
14
|
+
plt.cm.Set2(4/7),
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
WORKING_DIR = Path("/depict/users/hinge/private/hedypet/dynamic")
|
|
18
|
+
|
|
19
|
+
def _get_centerline(binary_mask_3d, projection_axis_idx):
|
|
20
|
+
true_value_coords = np.where(binary_mask_3d)
|
|
21
|
+
coords_on_last_dim_for_true = true_value_coords[-1]
|
|
22
|
+
coords_on_projection_axis_for_true = true_value_coords[projection_axis_idx]
|
|
23
|
+
|
|
24
|
+
unique_last_dim_indices = np.unique(coords_on_last_dim_for_true)
|
|
25
|
+
|
|
26
|
+
if not unique_last_dim_indices.size:
|
|
27
|
+
return np.zeros(binary_mask_3d.shape[-1], dtype=int)
|
|
28
|
+
|
|
29
|
+
fp_values = np.array([
|
|
30
|
+
coords_on_projection_axis_for_true[coords_on_last_dim_for_true == v_idx].mean().round()
|
|
31
|
+
for v_idx in unique_last_dim_indices
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
x_all_indices = np.arange(binary_mask_3d.shape[-1])
|
|
35
|
+
|
|
36
|
+
interpolated_centerline = np.interp(x_all_indices, unique_last_dim_indices, fp_values)
|
|
37
|
+
|
|
38
|
+
smoothed_interpolated_centerline = median_filter(interpolated_centerline, 5)
|
|
39
|
+
|
|
40
|
+
return smoothed_interpolated_centerline.astype(int)
|
|
41
|
+
|
|
42
|
+
def plot_aorta_visualizations(pet_array, segments_nifti, rois_nifti, save_path):
|
|
43
|
+
with tempfile.TemporaryDirectory() as tempdir:
|
|
44
|
+
tempdir_path = Path(tempdir)
|
|
45
|
+
|
|
46
|
+
fig_sagittal, axs_sagittal = plt.subplots(1, 10, figsize=(9, 6))
|
|
47
|
+
xlim_sag, ylim_sag = _plot_single_aorta_view(
|
|
48
|
+
pet_array, segments_nifti, rois_nifti,
|
|
49
|
+
view_axis=1, slice_definition="max",
|
|
50
|
+
ax_raw=axs_sagittal[0], ax_overlay=axs_sagittal[1]
|
|
51
|
+
)
|
|
52
|
+
axs_sagittal[0].set_title("MIP")
|
|
53
|
+
axs_sagittal[2].set_title("Unrolled aorta segments", loc="left")
|
|
54
|
+
axs_sagittal[0].set_ylabel("Sagittal")
|
|
55
|
+
for i in range(4):
|
|
56
|
+
_plot_single_aorta_view(
|
|
57
|
+
pet_array, segments_nifti, rois_nifti,
|
|
58
|
+
view_axis=1, slice_definition=i + 1,
|
|
59
|
+
ax_raw=axs_sagittal[(i + 1) * 2], ax_overlay=axs_sagittal[(i + 1) * 2 + 1],
|
|
60
|
+
xlim=xlim_sag, ylim=ylim_sag
|
|
61
|
+
)
|
|
62
|
+
plt.subplots_adjust(wspace=0, hspace=0)
|
|
63
|
+
plt.savefig(tempdir_path / "aorta_sagittal_views.jpg", dpi=300, bbox_inches='tight')
|
|
64
|
+
plt.close(fig_sagittal)
|
|
65
|
+
|
|
66
|
+
fig_coronal, axs_coronal = plt.subplots(1, 10, figsize=(9, 6))
|
|
67
|
+
xlim_cor, ylim_cor = _plot_single_aorta_view(
|
|
68
|
+
pet_array, segments_nifti, rois_nifti,
|
|
69
|
+
view_axis=0, slice_definition="max",
|
|
70
|
+
ax_raw=axs_coronal[0], ax_overlay=axs_coronal[1]
|
|
71
|
+
)
|
|
72
|
+
axs_coronal[0].set_ylabel("Coronal")
|
|
73
|
+
for i in range(4):
|
|
74
|
+
_plot_single_aorta_view(
|
|
75
|
+
pet_array, segments_nifti, rois_nifti,
|
|
76
|
+
view_axis=0, slice_definition=i + 1,
|
|
77
|
+
ax_raw=axs_coronal[(i + 1) * 2], ax_overlay=axs_coronal[(i + 1) * 2 + 1],
|
|
78
|
+
xlim=xlim_cor, ylim=ylim_cor
|
|
79
|
+
)
|
|
80
|
+
plt.subplots_adjust(wspace=0, hspace=0)
|
|
81
|
+
plt.savefig(tempdir_path / "aorta_coronal_views.jpg", dpi=300, bbox_inches='tight')
|
|
82
|
+
plt.close(fig_coronal)
|
|
83
|
+
|
|
84
|
+
img_sagittal = Image.open(tempdir_path / "aorta_sagittal_views.jpg")
|
|
85
|
+
img_coronal = Image.open(tempdir_path / "aorta_coronal_views.jpg")
|
|
86
|
+
combined_img_array = np.concatenate((np.array(img_sagittal), np.array(img_coronal)), axis=0)
|
|
87
|
+
Image.fromarray(combined_img_array).save(save_path)
|
|
88
|
+
|
|
89
|
+
def _plot_single_aorta_view(pet_array, segments_nifti, rois_nifti,
|
|
90
|
+
view_axis, slice_definition,
|
|
91
|
+
ax_raw, ax_overlay, xlim=None, ylim=None):
|
|
92
|
+
segments_array = segments_nifti.get_fdata()
|
|
93
|
+
rois_array = rois_nifti.get_fdata()
|
|
94
|
+
|
|
95
|
+
roi4_mask = (rois_array == 4)
|
|
96
|
+
vmax = pet_array[roi4_mask].mean() * 1.6 if np.any(roi4_mask) else pet_array.max() * 0.5
|
|
97
|
+
|
|
98
|
+
pet_view_2d, segments_view_2d, rois_view_2d = None, None, None
|
|
99
|
+
|
|
100
|
+
if slice_definition == "max":
|
|
101
|
+
pet_view_2d = np.rot90(pet_array.max(axis=view_axis))
|
|
102
|
+
segments_view_2d = np.rot90(segments_array.max(axis=view_axis))
|
|
103
|
+
rois_view_2d = np.rot90(rois_array.max(axis=view_axis))
|
|
104
|
+
elif isinstance(slice_definition, int):
|
|
105
|
+
centerline_coords = _get_centerline(segments_array == slice_definition, view_axis)
|
|
106
|
+
depth_indices = np.arange(pet_array.shape[2])
|
|
107
|
+
|
|
108
|
+
if view_axis == 0:
|
|
109
|
+
pet_view_2d = np.rot90((pet_array[centerline_coords, :, depth_indices]).T, 1)
|
|
110
|
+
rois_view_2d = np.rot90((rois_array[centerline_coords, :, depth_indices]).T, 1)
|
|
111
|
+
segments_view_2d = np.rot90((segments_array[centerline_coords, :, depth_indices]).T, 1)
|
|
112
|
+
elif view_axis == 1:
|
|
113
|
+
pet_view_2d = np.rot90(pet_array[:, centerline_coords, depth_indices])
|
|
114
|
+
rois_view_2d = np.rot90(rois_array[:, centerline_coords, depth_indices])
|
|
115
|
+
segments_view_2d = np.rot90(segments_array[:, centerline_coords, depth_indices])
|
|
116
|
+
else:
|
|
117
|
+
raise ValueError(f"Unsupported view_axis for centerline slicing: {view_axis}")
|
|
118
|
+
else:
|
|
119
|
+
raise ValueError(f"Invalid slice_definition argument: {slice_definition}")
|
|
120
|
+
|
|
121
|
+
active_region_coords_x, active_region_coords_y = (
|
|
122
|
+
np.where(segments_view_2d > 0) if slice_definition == "max"
|
|
123
|
+
else np.where(segments_view_2d == slice_definition)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if not active_region_coords_x.size or not active_region_coords_y.size:
|
|
127
|
+
xmin_calc, xmax_calc = 0, pet_view_2d.shape[0]
|
|
128
|
+
ymin_calc, ymax_calc = 0, pet_view_2d.shape[1]
|
|
129
|
+
else:
|
|
130
|
+
xmin_calc = max(0, active_region_coords_x.min() - 10)
|
|
131
|
+
xmax_calc = min(pet_view_2d.shape[0], active_region_coords_x.max() + 10)
|
|
132
|
+
ymin_calc = max(0, active_region_coords_y.min() - 10)
|
|
133
|
+
ymax_calc = min(pet_view_2d.shape[1], active_region_coords_y.max() + 10)
|
|
134
|
+
|
|
135
|
+
current_xlim = xlim if xlim is not None else [xmin_calc, xmax_calc]
|
|
136
|
+
current_ylim = ylim if ylim is not None else [ymin_calc, ymax_calc]
|
|
137
|
+
|
|
138
|
+
current_xlim = [max(0, int(current_xlim[0])), min(pet_view_2d.shape[0], int(current_xlim[1]))]
|
|
139
|
+
current_ylim = [max(0, int(current_ylim[0])), min(pet_view_2d.shape[1], int(current_ylim[1]))]
|
|
140
|
+
|
|
141
|
+
pet_cropped = pet_view_2d[current_xlim[0]:current_xlim[1], current_ylim[0]:current_ylim[1]]
|
|
142
|
+
segments_cropped = segments_view_2d[current_xlim[0]:current_xlim[1], current_ylim[0]:current_ylim[1]]
|
|
143
|
+
rois_cropped = rois_view_2d[current_xlim[0]:current_xlim[1], current_ylim[0]:current_ylim[1]]
|
|
144
|
+
|
|
145
|
+
segment_contours_mask = np.zeros(pet_cropped.shape, dtype=np.uint8)
|
|
146
|
+
for seg_val in [1, 2, 3, 4]:
|
|
147
|
+
binary_segment_for_contour = (segments_cropped == seg_val).astype("uint8")
|
|
148
|
+
contours, _ = cv2.findContours(binary_segment_for_contour, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
149
|
+
cv2.drawContours(segment_contours_mask, contours, -1, seg_val, thickness=1)
|
|
150
|
+
|
|
151
|
+
ax_raw.imshow(pet_cropped, cmap="gray_r", vmax=vmax)
|
|
152
|
+
ax_raw.set_xticks([])
|
|
153
|
+
ax_raw.set_yticks([])
|
|
154
|
+
|
|
155
|
+
ax_overlay.imshow(pet_cropped, cmap="gray_r", vmax=vmax)
|
|
156
|
+
ax_overlay.imshow(segment_contours_mask, alpha=(segment_contours_mask > 0) * 1.0, interpolation="nearest", vmin=1, vmax=4, cmap=_segmentation_cmap)
|
|
157
|
+
ax_overlay.imshow(segments_cropped, alpha=(segments_cropped > 0) * 0.4, interpolation="nearest", vmin=1, vmax=4, cmap=_segmentation_cmap)
|
|
158
|
+
ax_overlay.imshow(rois_cropped, alpha=(rois_cropped > 0) * 1.0, interpolation="nearest", vmin=1, vmax=4, cmap=_segmentation_cmap)
|
|
159
|
+
ax_overlay.set_xticks([])
|
|
160
|
+
ax_overlay.set_yticks([])
|
|
161
|
+
|
|
162
|
+
return current_xlim, current_ylim
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nifti_dynamic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A package for dynamic NIFTI analysis
|
|
5
|
+
Classifier: Programming Language :: Python :: 3
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: numpy
|
|
11
|
+
Requires-Dist: nibabel
|
|
12
|
+
Requires-Dist: indexed_gzip
|
|
13
|
+
Requires-Dist: scipy
|
|
14
|
+
Requires-Dist: scikit-learn
|
|
15
|
+
Requires-Dist: tqdm
|
|
16
|
+
Requires-Dist: ipython>=8.12.3
|
|
17
|
+
Requires-Dist: scikit-image>=0.21.0
|
|
18
|
+
Requires-Dist: matplotlib>=3.7.5
|
|
19
|
+
Requires-Dist: pandas>=2.0.3
|
|
20
|
+
|
|
21
|
+
# nifti_dynamic
|
|
22
|
+
|
|
23
|
+
A Python package for dynamic NIFTI analysis.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install nifti_dynamic
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
See example.py
|
|
34
|
+
|
|
35
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/nifti_dynamic/__init__.py
|
|
4
|
+
src/nifti_dynamic/aorta_rois.py
|
|
5
|
+
src/nifti_dynamic/conversion.py
|
|
6
|
+
src/nifti_dynamic/patlak.py
|
|
7
|
+
src/nifti_dynamic/utils.py
|
|
8
|
+
src/nifti_dynamic/visualizations.py
|
|
9
|
+
src/nifti_dynamic.egg-info/PKG-INFO
|
|
10
|
+
src/nifti_dynamic.egg-info/SOURCES.txt
|
|
11
|
+
src/nifti_dynamic.egg-info/dependency_links.txt
|
|
12
|
+
src/nifti_dynamic.egg-info/requires.txt
|
|
13
|
+
src/nifti_dynamic.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nifti_dynamic
|