ctaug 0.1.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.
- ctaug/__init__.py +23 -0
- ctaug/_base.py +14 -0
- ctaug/_resize.py +25 -0
- ctaug/metal/__init__.py +13 -0
- ctaug/metal/functional.py +272 -0
- ctaug/metal/transforms.py +346 -0
- ctaug/monai/__init__.py +24 -0
- ctaug/monai/_adapter.py +59 -0
- ctaug/monai/metal.py +144 -0
- ctaug/monai/motion.py +91 -0
- ctaug/step/__init__.py +11 -0
- ctaug/step/functional.py +317 -0
- ctaug/step/transforms.py +179 -0
- ctaug-0.1.0.dist-info/METADATA +549 -0
- ctaug-0.1.0.dist-info/RECORD +17 -0
- ctaug-0.1.0.dist-info/WHEEL +4 -0
- ctaug-0.1.0.dist-info/licenses/LICENSE +201 -0
ctaug/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from ctaug.metal import (
|
|
2
|
+
CalcificationTransform,
|
|
3
|
+
MetalTransform,
|
|
4
|
+
RandomArtifactTransform,
|
|
5
|
+
WireTransform,
|
|
6
|
+
)
|
|
7
|
+
from ctaug.step import (
|
|
8
|
+
MotionTransform,
|
|
9
|
+
StepTransform,
|
|
10
|
+
StepMotionTransform,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MetalTransform",
|
|
17
|
+
"WireTransform",
|
|
18
|
+
"CalcificationTransform",
|
|
19
|
+
"RandomArtifactTransform",
|
|
20
|
+
"StepTransform",
|
|
21
|
+
"MotionTransform",
|
|
22
|
+
"StepMotionTransform",
|
|
23
|
+
]
|
ctaug/_base.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DictTransform:
|
|
5
|
+
"""Base class for CTAug transforms operating on a dict of tensors.
|
|
6
|
+
|
|
7
|
+
Subclasses implement ``__call__(self, **data_dict)`` and return the
|
|
8
|
+
(possibly modified) ``data_dict``. This mirrors the dict-transform
|
|
9
|
+
convention used by batchgenerators/MONAI pipelines without requiring
|
|
10
|
+
either as a dependency, so CTAug's core stays torch-only.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __call__(self, **data_dict: Any) -> Dict[str, Any]:
|
|
14
|
+
raise NotImplementedError
|
ctaug/_resize.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import Sequence
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import torch
|
|
5
|
+
import torch.nn.functional as F
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resize_volume(volume: np.ndarray, target_size: Sequence[int], input_type: str) -> np.ndarray:
|
|
9
|
+
"""Resize a 3D array with torch, using an interpolation mode appropriate for images or labels.
|
|
10
|
+
|
|
11
|
+
:param volume: 3D array (Z, Y, X).
|
|
12
|
+
:param target_size: target (Z, Y, X) shape.
|
|
13
|
+
:param input_type: "image" for intensity data (trilinear) or "label" for segmentation masks (nearest).
|
|
14
|
+
"""
|
|
15
|
+
if input_type == "image":
|
|
16
|
+
mode, kwargs = "trilinear", {"align_corners": False}
|
|
17
|
+
elif input_type == "label":
|
|
18
|
+
mode, kwargs = "nearest-exact", {}
|
|
19
|
+
else:
|
|
20
|
+
raise ValueError(f"input_type: {input_type} is not correct!")
|
|
21
|
+
|
|
22
|
+
input_dtype = volume.dtype
|
|
23
|
+
tensor = torch.as_tensor(volume, dtype=torch.float32)[None, None, ...]
|
|
24
|
+
resized = F.interpolate(tensor, size=tuple(int(s) for s in target_size), mode=mode, **kwargs)
|
|
25
|
+
return resized[0, 0].numpy().astype(input_dtype)
|
ctaug/metal/__init__.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import warnings
|
|
3
|
+
from typing import Dict, List, Optional, Sequence, Union
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.ndimage import gaussian_filter
|
|
7
|
+
from skimage.transform import iradon, radon
|
|
8
|
+
from skimage.transform import resize as sk_resize
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def insert_2d_shape_mm(slice_2d: np.ndarray, center_px, radius_mm, spacing, shape: str = "circle",
|
|
12
|
+
intensity: float = 3000):
|
|
13
|
+
"""Burn a circle/ellipse/rectangle of the given HU intensity into a 2D slice."""
|
|
14
|
+
rr, cc = np.ogrid[:slice_2d.shape[0], :slice_2d.shape[1]]
|
|
15
|
+
r_center, c_center = center_px
|
|
16
|
+
if isinstance(radius_mm, (int, float)):
|
|
17
|
+
r_radius, c_radius = radius_mm / spacing[0], radius_mm / spacing[1]
|
|
18
|
+
else:
|
|
19
|
+
r_radius, c_radius = radius_mm[0] / spacing[0], radius_mm[1] / spacing[1]
|
|
20
|
+
|
|
21
|
+
if shape == "circle":
|
|
22
|
+
mask = (rr - r_center) ** 2 + (cc - c_center) ** 2 <= r_radius ** 2
|
|
23
|
+
elif shape == "ellipse":
|
|
24
|
+
mask = ((rr - r_center) ** 2) / r_radius ** 2 + ((cc - c_center) ** 2) / c_radius ** 2 <= 1
|
|
25
|
+
elif shape == "rectangle":
|
|
26
|
+
mask = (np.abs(rr - r_center) <= r_radius) & (np.abs(cc - c_center) <= c_radius)
|
|
27
|
+
else:
|
|
28
|
+
raise ValueError(f"Unsupported shape: {shape}")
|
|
29
|
+
slice_2d[mask] = intensity
|
|
30
|
+
return slice_2d, mask.astype(np.uint8)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_3d_ellipsoid_mask_mm(shape, center_mm, radius_mm, spacing) -> np.ndarray:
|
|
34
|
+
"""Boolean ellipsoid mask for a volume, with center/radius given in millimeters."""
|
|
35
|
+
center_vox = np.array(center_mm) / np.array(spacing)
|
|
36
|
+
radius_vox = np.array(radius_mm) / np.array(spacing)
|
|
37
|
+
zz, xx, yy = np.ogrid[:shape[0], :shape[1], :shape[2]]
|
|
38
|
+
cz, cx, cy = center_vox
|
|
39
|
+
rz, rx, ry = radius_vox
|
|
40
|
+
mask = ((yy - cy) / ry) ** 2 + ((xx - cx) / rx) ** 2 + ((zz - cz) / rz) ** 2 <= 1
|
|
41
|
+
return mask.astype(np.uint8)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_3d_ellipsoid_mask(shape, center, radius) -> np.ndarray:
|
|
45
|
+
"""Boolean ellipsoid mask for a volume, with center/radius given in voxels."""
|
|
46
|
+
center_vox = np.array(center)
|
|
47
|
+
radius_vox = np.array(radius)
|
|
48
|
+
zz, xx, yy = np.ogrid[:shape[0], :shape[1], :shape[2]]
|
|
49
|
+
cz, cx, cy = center_vox
|
|
50
|
+
rz, rx, ry = radius_vox
|
|
51
|
+
mask = ((yy - cy) / ry) ** 2 + ((xx - cx) / rx) ** 2 + ((zz - cz) / rz) ** 2 <= 1
|
|
52
|
+
return mask.astype(np.uint8)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def generate_moon_curve_path(length, center, radius, angle_range, z_range, spacing) -> np.ndarray:
|
|
56
|
+
"""Arc-shaped voxel path used to draw a curved wire (e.g. a pacemaker lead)."""
|
|
57
|
+
theta = np.linspace(angle_range[0], angle_range[1], length)
|
|
58
|
+
x = center[0] + radius * np.cos(theta)
|
|
59
|
+
y = center[1] + radius * np.sin(theta)
|
|
60
|
+
z = np.linspace(z_range[0], z_range[1], length)
|
|
61
|
+
x = x / spacing[1]
|
|
62
|
+
y = y / spacing[2]
|
|
63
|
+
z = z / spacing[0]
|
|
64
|
+
return np.stack([z, x, y], axis=1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def draw_wire_in_volume(shape, path_vox, wire_radius_mm, intensity, spacing) -> np.ndarray:
|
|
68
|
+
"""Rasterize a voxel path into a smoothed, high-intensity wire volume."""
|
|
69
|
+
vol = np.zeros(shape, dtype=np.float32)
|
|
70
|
+
for pt in path_vox.astype(int):
|
|
71
|
+
z, x, y = pt
|
|
72
|
+
if 0 <= y < shape[2] and 0 <= x < shape[1] and 0 <= z < shape[0]:
|
|
73
|
+
vol[z, x, y] = intensity
|
|
74
|
+
sigma_vox = np.array(wire_radius_mm) / np.array(spacing)
|
|
75
|
+
vol = gaussian_filter(vol, sigma=sigma_vox)
|
|
76
|
+
vol[vol > 0] = intensity
|
|
77
|
+
return vol
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def simulate_artifact(slice_2d: np.ndarray, mask_2d: np.ndarray, angles, severity: float = 0.1) -> np.ndarray:
|
|
81
|
+
"""Simulate radiating streak artifacts by attenuating the sinogram under the metal mask.
|
|
82
|
+
|
|
83
|
+
The slice is projected with the Radon transform, the sinogram rows explained by the
|
|
84
|
+
metal region are dampened by ``severity``, and the result is reconstructed with the
|
|
85
|
+
inverse Radon transform, mimicking the streaking seen around real metal implants.
|
|
86
|
+
"""
|
|
87
|
+
input_shape = slice_2d.shape
|
|
88
|
+
input_size = min(input_shape)
|
|
89
|
+
if input_shape != (input_size, input_size):
|
|
90
|
+
mask_2d = sk_resize(mask_2d.astype(np.uint8), (input_size, input_size), order=0,
|
|
91
|
+
preserve_range=True, anti_aliasing=False).astype(np.bool_)
|
|
92
|
+
slice_2d = sk_resize(slice_2d, (input_size, input_size), order=1,
|
|
93
|
+
preserve_range=True, anti_aliasing=True)
|
|
94
|
+
|
|
95
|
+
metal_only = np.where(mask_2d, slice_2d, 0)
|
|
96
|
+
R = radon(slice_2d, theta=angles, circle=False)
|
|
97
|
+
R_metal = radon(metal_only, theta=angles, circle=False)
|
|
98
|
+
R_weight = R_metal / np.max(R) if np.max(R) > 0 else R_metal
|
|
99
|
+
R_weight = np.clip(R_weight, 0, 1)
|
|
100
|
+
sino_new = R * (1 - severity * R_weight)
|
|
101
|
+
output = iradon(sino_new, theta=angles, filter_name="cosine", circle=False,
|
|
102
|
+
output_size=input_size).astype(np.float32)
|
|
103
|
+
output = sk_resize(output, input_shape, order=1, preserve_range=True,
|
|
104
|
+
anti_aliasing=True).astype(np.float32)
|
|
105
|
+
return output
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def process_slice(z: int, modified_data: np.ndarray, final_mask: np.ndarray, severity: float, angles) -> np.ndarray:
|
|
109
|
+
slice_2d = modified_data[z, :, :]
|
|
110
|
+
mask_2d = final_mask[z, :, :]
|
|
111
|
+
if np.any(mask_2d):
|
|
112
|
+
return simulate_artifact(slice_2d, mask_2d, angles=angles, severity=severity)
|
|
113
|
+
return slice_2d.astype(np.float32)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def mask_base_position_2d(mask: Optional[np.ndarray], data: np.ndarray, max_slice: int,
|
|
117
|
+
exclude_labels: Union[Sequence, int, None] = (0,),
|
|
118
|
+
include_labels: Union[Sequence, int, None] = None,
|
|
119
|
+
verbose: bool = False):
|
|
120
|
+
"""Pick a random (z-range, in-plane center) anchored on a segmentation label, when available.
|
|
121
|
+
|
|
122
|
+
Falls back to a uniformly random position (with ``selected_label`` set to ``-1``) if no
|
|
123
|
+
mask is given, the mask has no eligible labels, or a candidate region turns out empty.
|
|
124
|
+
"""
|
|
125
|
+
success = False
|
|
126
|
+
center = None
|
|
127
|
+
z_start = z_end = None
|
|
128
|
+
selected_label = -1
|
|
129
|
+
labels = []
|
|
130
|
+
if mask is not None:
|
|
131
|
+
crop_labels = set(np.unique(mask))
|
|
132
|
+
if include_labels is not None:
|
|
133
|
+
labels = (include_labels,) if isinstance(include_labels, int) else list(include_labels)
|
|
134
|
+
elif exclude_labels is not None:
|
|
135
|
+
exclude_labels = (exclude_labels,) if isinstance(exclude_labels, int) else exclude_labels
|
|
136
|
+
labels = list(crop_labels - set(exclude_labels))
|
|
137
|
+
else:
|
|
138
|
+
# no filter given: every label present in the mask is eligible
|
|
139
|
+
labels = list(crop_labels)
|
|
140
|
+
if labels:
|
|
141
|
+
for _ in range(5):
|
|
142
|
+
try:
|
|
143
|
+
selected_label = random.choice(labels)
|
|
144
|
+
zs, xs, ys = np.where(mask == selected_label)
|
|
145
|
+
min_z, max_z = min(zs), max(zs)
|
|
146
|
+
z_start = random.randint(min_z, max_z - 1)
|
|
147
|
+
z_end = random.randint(z_start + 1, min(max_z, z_start + 1 + max_slice))
|
|
148
|
+
|
|
149
|
+
_, xs, ys = np.where(mask[z_start:z_end, ...] == selected_label)
|
|
150
|
+
centers = [(x, y) for x, y in zip(xs, ys)]
|
|
151
|
+
if centers:
|
|
152
|
+
center = random.choice(centers)
|
|
153
|
+
success = True
|
|
154
|
+
break
|
|
155
|
+
except Exception as e:
|
|
156
|
+
if verbose:
|
|
157
|
+
warnings.warn(f"Error in mask_base_position_2d: {crop_labels=} -> {e=}")
|
|
158
|
+
continue
|
|
159
|
+
if not success:
|
|
160
|
+
z_start = random.randint(0, data.shape[0] - 1 - max_slice)
|
|
161
|
+
z_end = random.randint(z_start + 1, min(z_start + 1 + max_slice, data.shape[0]))
|
|
162
|
+
center = (random.randint(0, data.shape[1] - 1), random.randint(0, data.shape[2] - 1))
|
|
163
|
+
selected_label = -1
|
|
164
|
+
if verbose and mask is not None:
|
|
165
|
+
warnings.warn("No eligible segmentation label found in mask_base_position_2d, using a random position")
|
|
166
|
+
return (z_start, z_end), center, selected_label
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def mask_base_position_3d(mask: Optional[np.ndarray], data: np.ndarray,
|
|
170
|
+
exclude_labels: Union[Sequence, int, None] = (0,), include_labels: Union[Sequence, int, None] = None,
|
|
171
|
+
verbose: bool = False):
|
|
172
|
+
"""Pick a random 3D voxel anchored on a segmentation label, when available."""
|
|
173
|
+
success = False
|
|
174
|
+
center = None
|
|
175
|
+
selected_label = -1
|
|
176
|
+
labels = []
|
|
177
|
+
if mask is not None:
|
|
178
|
+
crop_labels = set(np.unique(mask))
|
|
179
|
+
if include_labels is not None:
|
|
180
|
+
labels = (include_labels,) if isinstance(include_labels, int) else list(include_labels)
|
|
181
|
+
elif exclude_labels is not None:
|
|
182
|
+
exclude_labels = (exclude_labels,) if isinstance(exclude_labels, int) else exclude_labels
|
|
183
|
+
labels = list(crop_labels - set(exclude_labels))
|
|
184
|
+
else:
|
|
185
|
+
# no filter given: every label present in the mask is eligible
|
|
186
|
+
labels = list(crop_labels)
|
|
187
|
+
if labels:
|
|
188
|
+
try:
|
|
189
|
+
selected_label = random.choice(labels)
|
|
190
|
+
zs, xs, ys = np.where(mask == selected_label)
|
|
191
|
+
centers = [(z, x, y) for z, x, y in zip(zs, xs, ys)]
|
|
192
|
+
center = random.choice(centers)
|
|
193
|
+
success = True
|
|
194
|
+
except Exception as e:
|
|
195
|
+
if verbose:
|
|
196
|
+
warnings.warn(f"Error in mask_base_position_3d: {crop_labels=} -> {e=}")
|
|
197
|
+
if not success:
|
|
198
|
+
selected_label = -1
|
|
199
|
+
center = [random.randint(0, data.shape[0] - 1),
|
|
200
|
+
random.randint(0, data.shape[1] - 1),
|
|
201
|
+
random.randint(0, data.shape[2] - 1)]
|
|
202
|
+
if verbose and mask is not None:
|
|
203
|
+
warnings.warn("No eligible segmentation label found in mask_base_position_3d, using a random position")
|
|
204
|
+
return center, selected_label
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def simulate_artifacts_unified(data: np.ndarray, implant_specs: List[Dict], spacing, angles,
|
|
208
|
+
severity: float = 0.05, smooth_sigma: float = 1.0,
|
|
209
|
+
verbose: bool = True) -> np.ndarray:
|
|
210
|
+
"""Insert one or more implant specs into ``data`` and simulate the resulting streak artifacts.
|
|
211
|
+
|
|
212
|
+
``implant_specs`` is a list of dicts, each with a ``"type"`` of ``"2d"``, ``"3d"``, or
|
|
213
|
+
``"wire"``; see :mod:`ctaug.metal.transforms` for how each type's parameters are sampled.
|
|
214
|
+
"""
|
|
215
|
+
shape = data.shape
|
|
216
|
+
final_mask = np.zeros_like(data, dtype=bool)
|
|
217
|
+
modified_data = data.copy().astype(np.float32)
|
|
218
|
+
|
|
219
|
+
for imp in implant_specs:
|
|
220
|
+
if imp["type"] == "2d":
|
|
221
|
+
for z in imp["slices"]:
|
|
222
|
+
modified_data[z, :, :], mask = insert_2d_shape_mm(
|
|
223
|
+
modified_data[z, :, :],
|
|
224
|
+
center_px=imp["center_px"],
|
|
225
|
+
radius_mm=imp["radius_mm"],
|
|
226
|
+
spacing=spacing[1:],
|
|
227
|
+
shape=imp["shape"],
|
|
228
|
+
intensity=imp.get("intensity", 3000),
|
|
229
|
+
)
|
|
230
|
+
final_mask[z, :, :] |= mask.astype(bool)
|
|
231
|
+
|
|
232
|
+
elif imp["type"] == "3d":
|
|
233
|
+
mask = create_3d_ellipsoid_mask_mm(
|
|
234
|
+
shape,
|
|
235
|
+
center_mm=imp["center_mm"],
|
|
236
|
+
radius_mm=imp["radius_mm"],
|
|
237
|
+
spacing=spacing,
|
|
238
|
+
)
|
|
239
|
+
if smooth_sigma > 0:
|
|
240
|
+
mask = gaussian_filter(mask.astype(float), sigma=smooth_sigma) > 0.2
|
|
241
|
+
modified_data[mask] = imp["intensity"]
|
|
242
|
+
final_mask |= mask
|
|
243
|
+
|
|
244
|
+
elif imp["type"] == "wire":
|
|
245
|
+
path = generate_moon_curve_path(
|
|
246
|
+
length=imp["length"],
|
|
247
|
+
center=imp["center_mm"],
|
|
248
|
+
radius=imp["arc_radius_mm"],
|
|
249
|
+
angle_range=imp["angle_range"],
|
|
250
|
+
z_range=imp["z_range_mm"],
|
|
251
|
+
spacing=spacing,
|
|
252
|
+
)
|
|
253
|
+
mask = draw_wire_in_volume(
|
|
254
|
+
shape,
|
|
255
|
+
path,
|
|
256
|
+
wire_radius_mm=imp["wire_radius_mm"],
|
|
257
|
+
intensity=imp["intensity"],
|
|
258
|
+
spacing=spacing,
|
|
259
|
+
)
|
|
260
|
+
modified_data[mask > 0] = imp["intensity"]
|
|
261
|
+
final_mask |= (mask > 0)
|
|
262
|
+
else:
|
|
263
|
+
raise ValueError(f"imp_type: {imp['type']} is not supported!")
|
|
264
|
+
|
|
265
|
+
if verbose and not final_mask.any():
|
|
266
|
+
warnings.warn(f"Nothing is in the mask for implant_specs={implant_specs}")
|
|
267
|
+
|
|
268
|
+
artifact_volume = np.stack(
|
|
269
|
+
[process_slice(z, modified_data, final_mask, severity=severity, angles=angles) for z in range(shape[0])],
|
|
270
|
+
axis=0,
|
|
271
|
+
)
|
|
272
|
+
return artifact_volume
|