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 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)
@@ -0,0 +1,13 @@
1
+ from ctaug.metal.transforms import (
2
+ CalcificationTransform,
3
+ MetalTransform,
4
+ RandomArtifactTransform,
5
+ WireTransform,
6
+ )
7
+
8
+ __all__ = [
9
+ "MetalTransform",
10
+ "WireTransform",
11
+ "CalcificationTransform",
12
+ "RandomArtifactTransform",
13
+ ]
@@ -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