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.
@@ -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,15 @@
1
+ # nifti_dynamic
2
+
3
+ A Python package for dynamic NIFTI analysis.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install nifti_dynamic
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ See example.py
14
+
15
+
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ try:
2
+ import indexed_gzip
3
+ except ImportError:
4
+ raise ImportError("The 'indexed_gzip' package is required for loading .nii.gz files fast. Please install it using 'pip install indexed_gzip'")
5
+
6
+ from .patlak import voxel_patlak
@@ -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,10 @@
1
+ numpy
2
+ nibabel
3
+ indexed_gzip
4
+ scipy
5
+ scikit-learn
6
+ tqdm
7
+ ipython>=8.12.3
8
+ scikit-image>=0.21.0
9
+ matplotlib>=3.7.5
10
+ pandas>=2.0.3
@@ -0,0 +1 @@
1
+ nifti_dynamic