seqseg 2.1.4__tar.gz → 2.1.5__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.
- {seqseg-2.1.4 → seqseg-2.1.5}/PKG-INFO +1 -1
- {seqseg-2.1.4 → seqseg-2.1.5}/pyproject.toml +1 -1
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/sitk_functions.py +62 -4
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/pipeline/classic.py +30 -9
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/pipeline/plus.py +27 -6
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/pipeline/train.py +83 -2
- seqseg-2.1.5/seqseg/scripts/prepare_casx_dataset.py +437 -0
- seqseg-2.1.5/seqseg/tests/test_prepare_casx_dataset.py +45 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/test_resample_spacing.py +36 -2
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/test_train_pipeline.py +55 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg.egg-info/PKG-INFO +1 -1
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg.egg-info/SOURCES.txt +2 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/LICENSE +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/README.md +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/__init__.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/api.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/citations.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/cli.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/config/__init__.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/config/aorta_tutorial.yaml +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/config/global.yaml +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/config/global_aorta.yaml +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/config/global_cereb.yaml +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/config/global_coro.yaml +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/config/global_debug.yaml +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/config/global_default.yaml +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/config/global_pulm.yaml +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/config/global_seg.yaml +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/config/global_test.yaml +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/config_models.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/__init__.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/assembly.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/capping.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/centerline.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/datasets.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/evaluation.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/initialization.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/local_assembly.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/nnunet.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/params.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/prediction.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/simvascular.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/simvascular_contour.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/sweep.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/tracing.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/tracing_functions.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/vmtk_functions.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/modules/vtk_functions.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/parallel/old_auto_centerline.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/parallel/parallel_auto_centerline.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/parallel/parallel_auto_centerline2.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/parallel/parallel_auto_centerline3.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/parallel/parallel_auto_centerline4.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/pipeline/__init__.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/pipeline/config.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/pipeline/directories.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/pipeline/post.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/pipeline/single_trace.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/scripts/__init__.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/scripts/taubin_smooth.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/seqseg.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/seqseg_plus.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/conftest.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/test_api_entry.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/test_centerline_get_end_points.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/test_centerline_gradient.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/test_cli_helpers.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/test_datasets_paths.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/test_nnunet2.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/test_nnunet_device.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/test_segmentation.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/test_single_trace_staging.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/test_start_seg.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/test_user_paths.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/tests/test_vtk.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg/user_paths.py +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg.egg-info/dependency_links.txt +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg.egg-info/entry_points.txt +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg.egg-info/requires.txt +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/seqseg.egg-info/top_level.txt +0 -0
- {seqseg-2.1.4 → seqseg-2.1.5}/setup.cfg +0 -0
|
@@ -55,10 +55,10 @@ def copy_settings(img, ref_img):
|
|
|
55
55
|
return img
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
DEFAULT_MAX_RESAMPLE_VOXELS =
|
|
58
|
+
# Cap assembly upsampling (e.g. CENT_MAX_SPACING in mm applied with ``-unit cm``)
|
|
59
|
+
# before SimpleITK tries to allocate a multi-billion-voxel grid and the process
|
|
60
|
+
# is SIGKILL'd (exit 137). 500M float32 voxels is ~2 GB for one volume.
|
|
61
|
+
DEFAULT_MAX_RESAMPLE_VOXELS = 500_000_000
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
def resampled_grid_size(orig_size, orig_spacing, new_spacing):
|
|
@@ -74,6 +74,64 @@ def resampled_grid_size(orig_size, orig_spacing, new_spacing):
|
|
|
74
74
|
)
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
def fit_spacing_to_max_voxels(
|
|
78
|
+
orig_size,
|
|
79
|
+
orig_spacing,
|
|
80
|
+
target_spacing,
|
|
81
|
+
max_voxels=DEFAULT_MAX_RESAMPLE_VOXELS,
|
|
82
|
+
):
|
|
83
|
+
"""
|
|
84
|
+
Coarsen ``target_spacing`` so the resampled grid has at most ``max_voxels``.
|
|
85
|
+
|
|
86
|
+
Axes that are already at ``orig_spacing`` are left unchanged. Remaining
|
|
87
|
+
(finer) axes are scaled uniformly. Never coarsens past ``orig_spacing``.
|
|
88
|
+
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
91
|
+
list[float] | None
|
|
92
|
+
Spacing to resample to, or ``None`` if no axis can be refined without
|
|
93
|
+
exceeding ``max_voxels`` (caller should skip resampling).
|
|
94
|
+
"""
|
|
95
|
+
if max_voxels is None:
|
|
96
|
+
return [float(s) for s in target_spacing]
|
|
97
|
+
|
|
98
|
+
orig_size = np.asarray(orig_size, dtype=np.float64).reshape(3)
|
|
99
|
+
orig_spacing = np.asarray(orig_spacing, dtype=np.float64).reshape(3)
|
|
100
|
+
target = np.asarray(target_spacing, dtype=np.float64).reshape(3)
|
|
101
|
+
max_voxels = int(max_voxels)
|
|
102
|
+
if max_voxels <= 0:
|
|
103
|
+
raise ValueError("max_voxels must be > 0")
|
|
104
|
+
|
|
105
|
+
def n_vox_for(spacing):
|
|
106
|
+
return int(np.prod(resampled_grid_size(orig_size, orig_spacing, spacing)))
|
|
107
|
+
|
|
108
|
+
n_vox = n_vox_for(target)
|
|
109
|
+
if n_vox <= max_voxels:
|
|
110
|
+
return target.tolist()
|
|
111
|
+
|
|
112
|
+
for _ in range(32):
|
|
113
|
+
finer = target < orig_spacing * (1.0 - 1e-12)
|
|
114
|
+
n_fine = int(np.count_nonzero(finer))
|
|
115
|
+
if n_fine == 0:
|
|
116
|
+
return None
|
|
117
|
+
k = (n_vox / float(max_voxels)) ** (1.0 / n_fine)
|
|
118
|
+
new_target = target.copy()
|
|
119
|
+
new_target[finer] = np.minimum(orig_spacing[finer], target[finer] * k)
|
|
120
|
+
if np.allclose(new_target, target):
|
|
121
|
+
new_target[finer] = np.minimum(
|
|
122
|
+
orig_spacing[finer], target[finer] * 1.001
|
|
123
|
+
)
|
|
124
|
+
if np.allclose(new_target, target):
|
|
125
|
+
return None
|
|
126
|
+
target = new_target
|
|
127
|
+
n_vox = n_vox_for(target)
|
|
128
|
+
if n_vox <= max_voxels:
|
|
129
|
+
if not np.any(target < orig_spacing):
|
|
130
|
+
return None
|
|
131
|
+
return target.tolist()
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
77
135
|
def resample_to_spacing(image, new_spacing, is_label=False,
|
|
78
136
|
max_voxels=DEFAULT_MAX_RESAMPLE_VOXELS):
|
|
79
137
|
"""
|
|
@@ -175,17 +175,38 @@ def run_classic_batch(
|
|
|
175
175
|
assembly.GetSpacing(), cent_max_spacing, unit
|
|
176
176
|
)
|
|
177
177
|
if target_spacing is not None:
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
178
|
+
fitted = sf.fit_spacing_to_max_voxels(
|
|
179
|
+
assembly.GetSize(),
|
|
180
|
+
assembly.GetSpacing(),
|
|
181
|
+
target_spacing,
|
|
181
182
|
)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
183
|
+
if fitted is None:
|
|
184
|
+
print(
|
|
185
|
+
"Skipping assembly upsample: even a coarsened grid "
|
|
186
|
+
f"would exceed {sf.DEFAULT_MAX_RESAMPLE_VOXELS:,} voxels"
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
if any(
|
|
190
|
+
abs(a - b) > 1e-12
|
|
191
|
+
for a, b in zip(fitted, target_spacing)
|
|
192
|
+
):
|
|
193
|
+
print(
|
|
194
|
+
"Relaxed assembly spacing from "
|
|
195
|
+
f"{target_spacing} to {fitted} to stay <= "
|
|
196
|
+
f"{sf.DEFAULT_MAX_RESAMPLE_VOXELS:,} voxels"
|
|
197
|
+
)
|
|
198
|
+
target_spacing = fitted
|
|
199
|
+
print(
|
|
200
|
+
f"Resampling final assembly to spacing <= "
|
|
201
|
+
f"{float(cent_max_spacing)} mm: {target_spacing}"
|
|
202
|
+
)
|
|
203
|
+
assembly = sf.resample_to_spacing(
|
|
204
|
+
assembly, target_spacing, is_label=False
|
|
188
205
|
)
|
|
206
|
+
if write_samples:
|
|
207
|
+
n_udpates = sf.resample_to_spacing(
|
|
208
|
+
n_udpates, target_spacing, is_label=True
|
|
209
|
+
)
|
|
189
210
|
|
|
190
211
|
assembly_binary = sitk.BinaryThreshold(
|
|
191
212
|
assembly,
|
|
@@ -206,13 +206,34 @@ def run_plus_batch(
|
|
|
206
206
|
assembly.GetSpacing(), cent_max_spacing, unit
|
|
207
207
|
)
|
|
208
208
|
if target_spacing is not None:
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
assembly = sf.resample_to_spacing(
|
|
214
|
-
assembly, target_spacing, is_label=False
|
|
209
|
+
fitted = sf.fit_spacing_to_max_voxels(
|
|
210
|
+
assembly.GetSize(),
|
|
211
|
+
assembly.GetSpacing(),
|
|
212
|
+
target_spacing,
|
|
215
213
|
)
|
|
214
|
+
if fitted is None:
|
|
215
|
+
print(
|
|
216
|
+
"Skipping assembly upsample: even a coarsened grid "
|
|
217
|
+
f"would exceed {sf.DEFAULT_MAX_RESAMPLE_VOXELS:,} voxels"
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
if any(
|
|
221
|
+
abs(a - b) > 1e-12
|
|
222
|
+
for a, b in zip(fitted, target_spacing)
|
|
223
|
+
):
|
|
224
|
+
print(
|
|
225
|
+
"Relaxed assembly spacing from "
|
|
226
|
+
f"{target_spacing} to {fitted} to stay <= "
|
|
227
|
+
f"{sf.DEFAULT_MAX_RESAMPLE_VOXELS:,} voxels"
|
|
228
|
+
)
|
|
229
|
+
target_spacing = fitted
|
|
230
|
+
print(
|
|
231
|
+
f"Resampling final assembly to spacing <= "
|
|
232
|
+
f"{float(cent_max_spacing)} mm: {target_spacing}"
|
|
233
|
+
)
|
|
234
|
+
assembly = sf.resample_to_spacing(
|
|
235
|
+
assembly, target_spacing, is_label=False
|
|
236
|
+
)
|
|
216
237
|
|
|
217
238
|
assembly_binary = sitk.BinaryThreshold(
|
|
218
239
|
assembly,
|
|
@@ -8,7 +8,9 @@ import subprocess
|
|
|
8
8
|
from contextlib import contextmanager
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from typing import Iterator, List, Optional, Sequence, Union
|
|
11
|
+
from typing import Any, Callable, Iterator, List, Optional, Sequence, Union
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
12
14
|
|
|
13
15
|
from seqseg.user_paths import apply_nnunet_env, ensure_nnunet_dirs, resolve_path
|
|
14
16
|
|
|
@@ -35,12 +37,84 @@ def _require_sampler():
|
|
|
35
37
|
return extract_patches, write_nnunet_dataset
|
|
36
38
|
|
|
37
39
|
|
|
40
|
+
def _reduction_or_nan(arr: Any, fn: Callable[[Any], Any]) -> float:
|
|
41
|
+
"""``np.amin``/``mean`` on an empty crop raise; treat that as missing."""
|
|
42
|
+
arr = np.asarray(arr)
|
|
43
|
+
if arr.size == 0:
|
|
44
|
+
return float("nan")
|
|
45
|
+
return float(fn(arr))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _point_list(location: Any) -> list:
|
|
49
|
+
if hasattr(location, "tolist"):
|
|
50
|
+
return location.tolist()
|
|
51
|
+
return list(location)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _safe_add_image_stats(stats: dict, im_np: Any) -> dict:
|
|
55
|
+
stats.update(
|
|
56
|
+
{
|
|
57
|
+
"IM_MEAN": _reduction_or_nan(im_np, np.mean),
|
|
58
|
+
"IM_MIN": _reduction_or_nan(im_np, np.amin),
|
|
59
|
+
"IM_STD": _reduction_or_nan(im_np, np.std),
|
|
60
|
+
"IM_MAX": _reduction_or_nan(im_np, np.amax),
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
return stats
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _safe_add_local_stats(
|
|
67
|
+
stats: dict,
|
|
68
|
+
location: Any,
|
|
69
|
+
diff_cent: Any,
|
|
70
|
+
blood_np: Any,
|
|
71
|
+
ground_truth: Any,
|
|
72
|
+
means: Sequence,
|
|
73
|
+
removed_seg: Any,
|
|
74
|
+
im_np: Any,
|
|
75
|
+
O: int,
|
|
76
|
+
) -> tuple[dict, int]:
|
|
77
|
+
"""Same stats as the sampler, but empty labeled crops become NaN not errors."""
|
|
78
|
+
stats.update(
|
|
79
|
+
{
|
|
80
|
+
"DIFF_CENT": diff_cent,
|
|
81
|
+
"POINT_CENT": _point_list(location),
|
|
82
|
+
"BLOOD_MEAN": _reduction_or_nan(blood_np, np.mean),
|
|
83
|
+
"BLOOD_MIN": _reduction_or_nan(blood_np, np.amin),
|
|
84
|
+
"BLOOD_STD": _reduction_or_nan(blood_np, np.std),
|
|
85
|
+
"BLOOD_MAX": _reduction_or_nan(blood_np, np.amax),
|
|
86
|
+
"GT_MEAN": _reduction_or_nan(ground_truth, np.mean),
|
|
87
|
+
"GT_STD": _reduction_or_nan(ground_truth, np.std),
|
|
88
|
+
"GT_MAX": _reduction_or_nan(ground_truth, np.amax),
|
|
89
|
+
"GT_MIN": _reduction_or_nan(ground_truth, np.amin),
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
if len(means) != 1:
|
|
93
|
+
import SimpleITK as sitk
|
|
94
|
+
|
|
95
|
+
larg_np = sitk.GetArrayFromImage(removed_seg)
|
|
96
|
+
rem_np = np.asarray(im_np)[np.asarray(larg_np) > 0.1]
|
|
97
|
+
stats.update(
|
|
98
|
+
{
|
|
99
|
+
"LARGEST_MEAN": _reduction_or_nan(rem_np, np.mean),
|
|
100
|
+
"LARGEST_STD": _reduction_or_nan(rem_np, np.std),
|
|
101
|
+
"LARGEST_MAX": _reduction_or_nan(rem_np, np.amax),
|
|
102
|
+
"LARGEST_MIN": _reduction_or_nan(rem_np, np.amin),
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
O += 1
|
|
106
|
+
return stats, O
|
|
107
|
+
|
|
108
|
+
|
|
38
109
|
def _harden_sampler_vtk() -> None:
|
|
39
|
-
"""
|
|
110
|
+
"""Patch sampler helpers that break on common SeqSeg datasets.
|
|
40
111
|
|
|
41
112
|
Centerline .vtp files from SimVascular/Slicer often include string/abstract
|
|
42
113
|
arrays. ``GetArray(i)`` is None for those, which became
|
|
43
114
|
``'NoneType' object has no attribute 'GetDataType'``.
|
|
115
|
+
|
|
116
|
+
Crops with no labeled voxels used to raise
|
|
117
|
+
``zero-size array to reduction operation minimum`` in ``add_local_stats``.
|
|
44
118
|
"""
|
|
45
119
|
try:
|
|
46
120
|
import vascular_segment_sampler.sampling.extract as extract_mod
|
|
@@ -57,6 +131,13 @@ def _harden_sampler_vtk() -> None:
|
|
|
57
131
|
samp_fn.collect_arrays = collect_arrays
|
|
58
132
|
vf.convertPolyDataToImageData = convertPolyDataToImageData
|
|
59
133
|
|
|
134
|
+
if not getattr(samp_fn.add_local_stats, "_seqseg_hardened", False):
|
|
135
|
+
_safe_add_local_stats._seqseg_hardened = True # type: ignore[attr-defined]
|
|
136
|
+
samp_fn.add_local_stats = _safe_add_local_stats
|
|
137
|
+
if not getattr(samp_fn.add_image_stats, "_seqseg_hardened", False):
|
|
138
|
+
_safe_add_image_stats._seqseg_hardened = True # type: ignore[attr-defined]
|
|
139
|
+
samp_fn.add_image_stats = _safe_add_image_stats
|
|
140
|
+
|
|
60
141
|
orig_sort = getattr(samp_fn, "sort_centerline", None)
|
|
61
142
|
if orig_sort is not None and not getattr(orig_sort, "_seqseg_hardened", False):
|
|
62
143
|
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Convert ImageCAS / CAS-X coronary data into a SeqSeg dataset (native mm).
|
|
3
|
+
|
|
4
|
+
Output layout under ``--path``:
|
|
5
|
+
|
|
6
|
+
images/{case}.nii.gz CTA, mm, identity direction
|
|
7
|
+
truths/{case}.nii.gz from source/segmentations/{id}.coronary.nii.gz only
|
|
8
|
+
centerlines/{case}.vtp merged L/R anatomical, mm, with radius
|
|
9
|
+
surfaces/{case}.vtp anatomical surfaces, mm
|
|
10
|
+
seeds.json
|
|
11
|
+
|
|
12
|
+
Images are taken from ``source/images_*/{id}.img.nii.gz``.
|
|
13
|
+
Does not use ``{id}.label.nii.gz``.
|
|
14
|
+
|
|
15
|
+
vascular-segment-sampler maps points as ``(point - origin) / spacing`` and
|
|
16
|
+
ignores the direction matrix. ImageCAS volumes are Y-flipped (dirY=-1); this
|
|
17
|
+
script flips that axis and moves the origin so meshes and volumes share space.
|
|
18
|
+
|
|
19
|
+
Examples::
|
|
20
|
+
|
|
21
|
+
python -m seqseg.scripts.prepare_casx_dataset --path /path/to/CAS_X_coronary_dataset
|
|
22
|
+
python -m seqseg.scripts.prepare_casx_dataset --path /path/to/CAS_X_coronary_dataset --fix-direction
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import shutil
|
|
31
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import SimpleITK as sitk
|
|
36
|
+
import vtk
|
|
37
|
+
from vtk.util.numpy_support import numpy_to_vtk as n2v
|
|
38
|
+
from vtk.util.numpy_support import vtk_to_numpy as v2n
|
|
39
|
+
|
|
40
|
+
DEFAULT_RADIUS_MM = 2.0
|
|
41
|
+
IDENTITY_DIRECTION = (1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_poly(path: Path) -> vtk.vtkPolyData:
|
|
45
|
+
path = Path(path)
|
|
46
|
+
if path.suffix.lower() == ".vtp":
|
|
47
|
+
reader = vtk.vtkXMLPolyDataReader()
|
|
48
|
+
else:
|
|
49
|
+
reader = vtk.vtkPolyDataReader()
|
|
50
|
+
reader.SetFileName(str(path))
|
|
51
|
+
reader.Update()
|
|
52
|
+
out = vtk.vtkPolyData()
|
|
53
|
+
out.DeepCopy(reader.GetOutput())
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_vtp(poly: vtk.vtkPolyData, path: Path) -> None:
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
writer = vtk.vtkXMLPolyDataWriter()
|
|
60
|
+
writer.SetFileName(str(path))
|
|
61
|
+
writer.SetInputData(poly)
|
|
62
|
+
writer.SetDataModeToBinary()
|
|
63
|
+
writer.Write()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def append_poly(*polys: vtk.vtkPolyData) -> vtk.vtkPolyData:
|
|
67
|
+
app = vtk.vtkAppendPolyData()
|
|
68
|
+
for p in polys:
|
|
69
|
+
if p is not None and p.GetNumberOfPoints() > 0:
|
|
70
|
+
app.AddInputData(p)
|
|
71
|
+
app.Update()
|
|
72
|
+
out = vtk.vtkPolyData()
|
|
73
|
+
out.DeepCopy(app.GetOutput())
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def add_inscribed_radius(centerline: vtk.vtkPolyData, surface: vtk.vtkPolyData) -> None:
|
|
78
|
+
implicit = vtk.vtkImplicitPolyDataDistance()
|
|
79
|
+
implicit.SetInput(surface)
|
|
80
|
+
n = centerline.GetNumberOfPoints()
|
|
81
|
+
radii = np.empty(n, dtype=np.float64)
|
|
82
|
+
for i in range(n):
|
|
83
|
+
radii[i] = abs(implicit.EvaluateFunction(centerline.GetPoint(i)))
|
|
84
|
+
vtk_arr = n2v(radii, deep=True)
|
|
85
|
+
vtk_arr.SetName("MaximumInscribedSphereRadius")
|
|
86
|
+
centerline.GetPointData().AddArray(vtk_arr)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def radius_at(poly: vtk.vtkPolyData, point_id: int, default: float = DEFAULT_RADIUS_MM) -> float:
|
|
90
|
+
rads = poly.GetPointData().GetArray("MaximumInscribedSphereRadius")
|
|
91
|
+
if rads is None:
|
|
92
|
+
return default
|
|
93
|
+
return max(float(rads.GetTuple1(point_id)), 0.5)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def anatomical_ostia(poly: vtk.vtkPolyData) -> list:
|
|
97
|
+
starts = poly.GetPointData().GetArray("start_points")
|
|
98
|
+
if starts is None:
|
|
99
|
+
return []
|
|
100
|
+
sv = v2n(starts)
|
|
101
|
+
idx = np.where(sv > 0)[0]
|
|
102
|
+
seeds = []
|
|
103
|
+
for sid in idx:
|
|
104
|
+
sid = int(sid)
|
|
105
|
+
start = np.array(poly.GetPoint(sid))
|
|
106
|
+
direction = None
|
|
107
|
+
for c in range(poly.GetNumberOfCells()):
|
|
108
|
+
cell = poly.GetCell(c)
|
|
109
|
+
ids = [cell.GetPointId(k) for k in range(cell.GetNumberOfPoints())]
|
|
110
|
+
if sid not in ids:
|
|
111
|
+
continue
|
|
112
|
+
pos = ids.index(sid)
|
|
113
|
+
if pos + 8 < len(ids):
|
|
114
|
+
direction = np.array(poly.GetPoint(ids[pos + 8]))
|
|
115
|
+
elif pos - 8 >= 0:
|
|
116
|
+
direction = np.array(poly.GetPoint(ids[pos - 8]))
|
|
117
|
+
elif len(ids) > 1:
|
|
118
|
+
other = ids[-1] if pos == 0 else ids[0]
|
|
119
|
+
direction = np.array(poly.GetPoint(other))
|
|
120
|
+
break
|
|
121
|
+
if direction is None:
|
|
122
|
+
continue
|
|
123
|
+
seeds.append((start, direction, radius_at(poly, sid)))
|
|
124
|
+
return seeds
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def unique_ostia(poly: vtk.vtkPolyData, min_points: int = 20):
|
|
128
|
+
ostia = []
|
|
129
|
+
seen = []
|
|
130
|
+
for c in range(poly.GetNumberOfCells()):
|
|
131
|
+
cell = poly.GetCell(c)
|
|
132
|
+
n = cell.GetNumberOfPoints()
|
|
133
|
+
if n < min_points:
|
|
134
|
+
continue
|
|
135
|
+
ids = [cell.GetPointId(k) for k in range(n)]
|
|
136
|
+
rads = poly.GetPointData().GetArray("MaximumInscribedSphereRadius")
|
|
137
|
+
if rads is not None:
|
|
138
|
+
r0 = rads.GetTuple1(ids[0])
|
|
139
|
+
r1 = rads.GetTuple1(ids[-1])
|
|
140
|
+
if r1 > r0:
|
|
141
|
+
ids = list(reversed(ids))
|
|
142
|
+
start = np.array(poly.GetPoint(ids[0]))
|
|
143
|
+
if any(np.linalg.norm(start - s) < 1e-2 for s in seen):
|
|
144
|
+
continue
|
|
145
|
+
seen.append(start)
|
|
146
|
+
step = min(12, max(3, n // 20))
|
|
147
|
+
direction = np.array(poly.GetPoint(ids[step]))
|
|
148
|
+
ostia.append((start, direction, radius_at(poly, ids[0]), n))
|
|
149
|
+
ostia.sort(key=lambda t: t[2] * t[3], reverse=True)
|
|
150
|
+
return ostia[:2]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def canonicalize_identity_direction(img: sitk.Image) -> sitk.Image:
|
|
154
|
+
"""Flip negative-direction axes so origin+spacing matches mesh coordinates."""
|
|
155
|
+
direction = np.array(img.GetDirection(), dtype=float).reshape(3, 3)
|
|
156
|
+
if np.allclose(direction, np.eye(3)):
|
|
157
|
+
return img
|
|
158
|
+
size = np.array(img.GetSize(), dtype=float)
|
|
159
|
+
spacing = np.array(img.GetSpacing(), dtype=float)
|
|
160
|
+
origin = np.array(img.GetOrigin(), dtype=float)
|
|
161
|
+
arr = sitk.GetArrayFromImage(img)
|
|
162
|
+
new_origin = origin.copy()
|
|
163
|
+
# SimpleITK index axes (x, y, z) <-> numpy array axes (z, y, x)
|
|
164
|
+
for ax, np_ax in enumerate((2, 1, 0)):
|
|
165
|
+
if direction[ax, ax] >= 0:
|
|
166
|
+
continue
|
|
167
|
+
arr = np.flip(arr, axis=np_ax)
|
|
168
|
+
new_origin[ax] = origin[ax] + direction[ax, ax] * (size[ax] - 1) * spacing[ax]
|
|
169
|
+
out = sitk.GetImageFromArray(arr)
|
|
170
|
+
out.CopyInformation(img)
|
|
171
|
+
out.SetOrigin(tuple(float(v) for v in new_origin))
|
|
172
|
+
out.SetDirection(IDENTITY_DIRECTION)
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def write_canonical_volume(src: Path, dst: Path) -> None:
|
|
177
|
+
img = canonicalize_identity_direction(sitk.ReadImage(str(src)))
|
|
178
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
if dst.exists() or dst.is_symlink():
|
|
180
|
+
dst.unlink()
|
|
181
|
+
sitk.WriteImage(img, str(dst), useCompression=True)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def fix_one_volume(path_str: str) -> tuple[str, str]:
|
|
185
|
+
path = Path(path_str)
|
|
186
|
+
img = sitk.ReadImage(str(path))
|
|
187
|
+
direction = img.GetDirection()
|
|
188
|
+
if all(abs(a - b) < 1e-9 for a, b in zip(direction, IDENTITY_DIRECTION)):
|
|
189
|
+
return path.name, "skip"
|
|
190
|
+
out = canonicalize_identity_direction(img)
|
|
191
|
+
if path.exists():
|
|
192
|
+
path.unlink()
|
|
193
|
+
sitk.WriteImage(out, str(path), useCompression=True)
|
|
194
|
+
return path.name, "wrote"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def image_dirs(source: Path) -> list[Path]:
|
|
198
|
+
return sorted(p for p in source.glob("images_*") if p.is_dir())
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def find_image(case: str, source: Path) -> Path | None:
|
|
202
|
+
for d in image_dirs(source):
|
|
203
|
+
p = d / f"{case}.img.nii.gz"
|
|
204
|
+
if p.is_file():
|
|
205
|
+
return p
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def move_originals(root: Path) -> dict:
|
|
210
|
+
source = root / "source"
|
|
211
|
+
mapping = {
|
|
212
|
+
root / "images" / "1-200": source / "images_1-200",
|
|
213
|
+
root / "images" / "sv_smooth_surface": source / "sv_smooth_surface",
|
|
214
|
+
root / "centerlines": source / "centerlines_anatomical",
|
|
215
|
+
root / "surfaces": source / "surfaces_anatomical",
|
|
216
|
+
root / "segmentations": source / "segmentations",
|
|
217
|
+
}
|
|
218
|
+
source.mkdir(exist_ok=True)
|
|
219
|
+
for src, dst in mapping.items():
|
|
220
|
+
if src.exists() and not dst.exists():
|
|
221
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
shutil.move(str(src), str(dst))
|
|
223
|
+
print(f"moved {src.relative_to(root)} -> {dst.relative_to(root)}")
|
|
224
|
+
(root / "images").mkdir(exist_ok=True)
|
|
225
|
+
dirs = image_dirs(source)
|
|
226
|
+
print("image source folders:", ", ".join(d.name for d in dirs) or "(none)")
|
|
227
|
+
return {
|
|
228
|
+
"root": root,
|
|
229
|
+
"source": source,
|
|
230
|
+
"image_dirs": dirs,
|
|
231
|
+
"cent_anat": source / "centerlines_anatomical",
|
|
232
|
+
"surf_anat": source / "surfaces_anatomical",
|
|
233
|
+
"segs": source / "segmentations",
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def case_ids(paths: dict) -> list[str]:
|
|
238
|
+
ids = set()
|
|
239
|
+
for p in paths["segs"].glob("*.coronary.nii.gz"):
|
|
240
|
+
ids.add(p.name.split(".")[0])
|
|
241
|
+
for d in paths["image_dirs"]:
|
|
242
|
+
for p in d.glob("*.img.nii.gz"):
|
|
243
|
+
ids.add(p.name.split(".")[0])
|
|
244
|
+
return sorted(ids, key=lambda x: int(x) if x.isdigit() else x)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def refresh_seed_radii(seed_list: list, merged: vtk.vtkPolyData) -> list:
|
|
248
|
+
if not seed_list:
|
|
249
|
+
ostia = unique_ostia(merged)
|
|
250
|
+
return [(s, d, r) for s, d, r, _ in ostia]
|
|
251
|
+
pts = np.array([merged.GetPoint(i) for i in range(merged.GetNumberOfPoints())])
|
|
252
|
+
if pts.size == 0:
|
|
253
|
+
return seed_list
|
|
254
|
+
refreshed = []
|
|
255
|
+
for start, direction, radius in seed_list:
|
|
256
|
+
dist = np.linalg.norm(pts - np.asarray(start), axis=1)
|
|
257
|
+
pid = int(np.argmin(dist))
|
|
258
|
+
refreshed.append((start, direction, radius_at(merged, pid, radius)))
|
|
259
|
+
return refreshed
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def process_case(case: str, root_str: str) -> dict:
|
|
263
|
+
root = Path(root_str)
|
|
264
|
+
source = root / "source"
|
|
265
|
+
cent_anat = source / "centerlines_anatomical"
|
|
266
|
+
surf_anat = source / "surfaces_anatomical"
|
|
267
|
+
segs = source / "segmentations"
|
|
268
|
+
|
|
269
|
+
img_orig = find_image(case, source)
|
|
270
|
+
seg = segs / f"{case}.coronary.nii.gz"
|
|
271
|
+
surf_src = surf_anat / f"{case}.coronary_surface.vtk"
|
|
272
|
+
left = cent_anat / f"{case}.coronary_left_centerline.vtk"
|
|
273
|
+
right = cent_anat / f"{case}.coronary_right_centerline.vtk"
|
|
274
|
+
|
|
275
|
+
out_img = root / "images" / f"{case}.nii.gz"
|
|
276
|
+
out_truth = root / "truths" / f"{case}.nii.gz"
|
|
277
|
+
out_cent = root / "centerlines" / f"{case}.vtp"
|
|
278
|
+
out_surf = root / "surfaces" / f"{case}.vtp"
|
|
279
|
+
|
|
280
|
+
for d in ("images", "truths", "centerlines", "surfaces"):
|
|
281
|
+
(root / d).mkdir(exist_ok=True)
|
|
282
|
+
|
|
283
|
+
status = {
|
|
284
|
+
"case": case,
|
|
285
|
+
"image": False,
|
|
286
|
+
"truth": False,
|
|
287
|
+
"centerline": False,
|
|
288
|
+
"surface": False,
|
|
289
|
+
"seeds": [],
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if img_orig is not None:
|
|
293
|
+
write_canonical_volume(img_orig, out_img)
|
|
294
|
+
status["image"] = True
|
|
295
|
+
|
|
296
|
+
if seg.exists():
|
|
297
|
+
write_canonical_volume(seg, out_truth)
|
|
298
|
+
status["truth"] = True
|
|
299
|
+
|
|
300
|
+
parts = []
|
|
301
|
+
seed_list = []
|
|
302
|
+
for branch in (left, right):
|
|
303
|
+
if not branch.exists():
|
|
304
|
+
continue
|
|
305
|
+
p = read_poly(branch)
|
|
306
|
+
parts.append(p)
|
|
307
|
+
seed_list.extend(anatomical_ostia(p))
|
|
308
|
+
if parts:
|
|
309
|
+
if out_cent.exists():
|
|
310
|
+
merged = read_poly(out_cent)
|
|
311
|
+
seed_list = refresh_seed_radii(seed_list, merged)
|
|
312
|
+
else:
|
|
313
|
+
merged = append_poly(*parts)
|
|
314
|
+
if surf_src.exists():
|
|
315
|
+
add_inscribed_radius(merged, read_poly(surf_src))
|
|
316
|
+
seed_list = refresh_seed_radii(seed_list, merged)
|
|
317
|
+
write_vtp(merged, out_cent)
|
|
318
|
+
status["centerline"] = True
|
|
319
|
+
status["seeds"] = [[s.tolist(), d.tolist(), float(r)] for s, d, r in seed_list]
|
|
320
|
+
|
|
321
|
+
if surf_src.exists():
|
|
322
|
+
if not out_surf.exists():
|
|
323
|
+
write_vtp(read_poly(surf_src), out_surf)
|
|
324
|
+
status["surface"] = True
|
|
325
|
+
|
|
326
|
+
return status
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def n_workers(requested: int | None) -> int:
|
|
330
|
+
if requested is not None and requested > 0:
|
|
331
|
+
return requested
|
|
332
|
+
return max(1, min(8, os.cpu_count() or 4))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def fix_existing_volumes(root: Path, workers: int) -> None:
|
|
336
|
+
paths = [str(p) for p in sorted((root / "images").glob("*.nii.gz"))]
|
|
337
|
+
paths += [str(p) for p in sorted((root / "truths").glob("*.nii.gz"))]
|
|
338
|
+
print(f"Fixing direction on {len(paths)} volumes ({workers} workers)")
|
|
339
|
+
n_wrote = n_skip = 0
|
|
340
|
+
with ProcessPoolExecutor(max_workers=workers) as ex:
|
|
341
|
+
futs = {ex.submit(fix_one_volume, p): p for p in paths}
|
|
342
|
+
done = 0
|
|
343
|
+
for fut in as_completed(futs):
|
|
344
|
+
_name, status = fut.result()
|
|
345
|
+
n_wrote += int(status == "wrote")
|
|
346
|
+
n_skip += int(status == "skip")
|
|
347
|
+
done += 1
|
|
348
|
+
if done % 50 == 0 or done == len(paths):
|
|
349
|
+
print(f" {done}/{len(paths)} wrote={n_wrote} skip={n_skip}")
|
|
350
|
+
print(f"direction fix done wrote={n_wrote} already_identity={n_skip}")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def run_prepare(root: Path, workers: int) -> None:
|
|
354
|
+
paths = move_originals(root)
|
|
355
|
+
cases = case_ids(paths)
|
|
356
|
+
print(
|
|
357
|
+
f"{len(cases)} cases, {workers} workers "
|
|
358
|
+
"(mm, truths from segmentations/, images from source/images_*)"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
results = []
|
|
362
|
+
with ProcessPoolExecutor(max_workers=workers) as ex:
|
|
363
|
+
futs = {ex.submit(process_case, c, str(root)): c for c in cases}
|
|
364
|
+
done = 0
|
|
365
|
+
for fut in as_completed(futs):
|
|
366
|
+
case = futs[fut]
|
|
367
|
+
try:
|
|
368
|
+
res = fut.result()
|
|
369
|
+
except Exception as e: # noqa: BLE001
|
|
370
|
+
res = {"case": case, "error": str(e)}
|
|
371
|
+
results.append(res)
|
|
372
|
+
done += 1
|
|
373
|
+
if done % 25 == 0 or done == len(cases):
|
|
374
|
+
print(f" {done}/{len(cases)}")
|
|
375
|
+
|
|
376
|
+
seeds = []
|
|
377
|
+
n_img = n_truth = n_cent = n_surf = n_err = 0
|
|
378
|
+
for r in sorted(
|
|
379
|
+
results,
|
|
380
|
+
key=lambda x: int(x.get("case", "0")) if str(x.get("case", "0")).isdigit() else 0,
|
|
381
|
+
):
|
|
382
|
+
if r.get("error"):
|
|
383
|
+
n_err += 1
|
|
384
|
+
print("ERROR", r["case"], r["error"])
|
|
385
|
+
continue
|
|
386
|
+
n_img += int(bool(r.get("image")))
|
|
387
|
+
n_truth += int(bool(r.get("truth")))
|
|
388
|
+
n_cent += int(bool(r.get("centerline")))
|
|
389
|
+
n_surf += int(bool(r.get("surface")))
|
|
390
|
+
if r.get("image"):
|
|
391
|
+
seeds.append(
|
|
392
|
+
{
|
|
393
|
+
"name": r["case"],
|
|
394
|
+
"seeds": r.get("seeds") or [],
|
|
395
|
+
"cardiac_mesh": False,
|
|
396
|
+
}
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
seeds.sort(key=lambda s: int(s["name"]) if str(s["name"]).isdigit() else s["name"])
|
|
400
|
+
seeds_path = root / "seeds.json"
|
|
401
|
+
with open(seeds_path, "w", encoding="utf-8") as f:
|
|
402
|
+
json.dump(seeds, f, indent=2)
|
|
403
|
+
|
|
404
|
+
print(
|
|
405
|
+
f"done images={n_img} truths={n_truth} centerlines={n_cent} "
|
|
406
|
+
f"surfaces={n_surf} errors={n_err} seeds={len(seeds)} -> {seeds_path}"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def main(argv: list[str] | None = None) -> None:
|
|
411
|
+
parser = argparse.ArgumentParser(
|
|
412
|
+
description="Convert ImageCAS / CAS-X coronary data into a SeqSeg dataset.",
|
|
413
|
+
)
|
|
414
|
+
parser.add_argument(
|
|
415
|
+
"--path",
|
|
416
|
+
type=str,
|
|
417
|
+
default=".",
|
|
418
|
+
help="Dataset root (contains source/ or native ImageCAS folders)",
|
|
419
|
+
)
|
|
420
|
+
parser.add_argument(
|
|
421
|
+
"--fix-direction",
|
|
422
|
+
action="store_true",
|
|
423
|
+
help="Rewrite existing images/ and truths/ to identity direction (mm)",
|
|
424
|
+
)
|
|
425
|
+
parser.add_argument("--num-cores", type=int, default=None, help="Worker processes")
|
|
426
|
+
args = parser.parse_args(argv)
|
|
427
|
+
|
|
428
|
+
root = Path(args.path).expanduser().resolve()
|
|
429
|
+
workers = n_workers(args.num_cores)
|
|
430
|
+
if args.fix_direction:
|
|
431
|
+
fix_existing_volumes(root, workers)
|
|
432
|
+
return
|
|
433
|
+
run_prepare(root, workers)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
if __name__ == "__main__":
|
|
437
|
+
main()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Tests for ImageCAS / CAS-X direction canonicalization."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import SimpleITK as sitk
|
|
5
|
+
|
|
6
|
+
from seqseg.scripts.prepare_casx_dataset import (
|
|
7
|
+
IDENTITY_DIRECTION,
|
|
8
|
+
canonicalize_identity_direction,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_canonicalize_flips_negative_y_and_keeps_physical_point():
|
|
13
|
+
arr = np.arange(2 * 4 * 3, dtype=np.int16).reshape(2, 4, 3)
|
|
14
|
+
img = sitk.GetImageFromArray(arr)
|
|
15
|
+
img.SetSpacing((0.5, 0.4, 1.0))
|
|
16
|
+
img.SetOrigin((1.0, 10.0, 3.0))
|
|
17
|
+
img.SetDirection((1.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 1.0))
|
|
18
|
+
|
|
19
|
+
ijk = (1, 3, 0)
|
|
20
|
+
phys = np.array(img.TransformIndexToPhysicalPoint(ijk))
|
|
21
|
+
|
|
22
|
+
out = canonicalize_identity_direction(img)
|
|
23
|
+
assert tuple(round(v, 9) for v in out.GetDirection()) == IDENTITY_DIRECTION
|
|
24
|
+
|
|
25
|
+
ny = img.GetSize()[1]
|
|
26
|
+
ijk_flipped = (ijk[0], ny - 1 - ijk[1], ijk[2])
|
|
27
|
+
phys_out = np.array(out.TransformIndexToPhysicalPoint(ijk_flipped))
|
|
28
|
+
np.testing.assert_allclose(phys, phys_out)
|
|
29
|
+
|
|
30
|
+
# Sampler-style mapping (ignores direction) must land in-bounds.
|
|
31
|
+
origin = np.array(out.GetOrigin())
|
|
32
|
+
spacing = np.array(out.GetSpacing())
|
|
33
|
+
size = np.array(out.GetSize())
|
|
34
|
+
idx = (phys_out - origin) / spacing
|
|
35
|
+
assert np.all(idx >= 0) and np.all(idx <= size - 1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_canonicalize_noop_when_already_identity():
|
|
39
|
+
img = sitk.GetImageFromArray(np.ones((2, 2, 2), dtype=np.uint8))
|
|
40
|
+
img.SetDirection(IDENTITY_DIRECTION)
|
|
41
|
+
origin = img.GetOrigin()
|
|
42
|
+
out = canonicalize_identity_direction(img)
|
|
43
|
+
assert out.GetDirection() == IDENTITY_DIRECTION
|
|
44
|
+
assert out.GetOrigin() == origin
|
|
45
|
+
np.testing.assert_array_equal(sitk.GetArrayFromImage(img), sitk.GetArrayFromImage(out))
|
|
@@ -6,7 +6,9 @@ import SimpleITK as sitk
|
|
|
6
6
|
|
|
7
7
|
from seqseg.config_models import AlgorithmConfig
|
|
8
8
|
from seqseg.modules.sitk_functions import (
|
|
9
|
+
DEFAULT_MAX_RESAMPLE_VOXELS,
|
|
9
10
|
capped_target_spacing,
|
|
11
|
+
fit_spacing_to_max_voxels,
|
|
10
12
|
resample_to_spacing,
|
|
11
13
|
resampled_grid_size,
|
|
12
14
|
)
|
|
@@ -28,8 +30,8 @@ def test_capped_target_spacing_cm_is_tenfold_finer():
|
|
|
28
30
|
n_vox = int(np.prod(resampled_grid_size(
|
|
29
31
|
(512, 512, 258), (0.65, 0.65, 1.0), target
|
|
30
32
|
)))
|
|
31
|
-
# ~
|
|
32
|
-
assert n_vox >
|
|
33
|
+
# ~1e12 voxels — this is what SIGKILL'd the macos CI job.
|
|
34
|
+
assert n_vox > DEFAULT_MAX_RESAMPLE_VOXELS
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
def test_resampled_grid_size_identity():
|
|
@@ -55,3 +57,35 @@ def test_resample_to_spacing_small_grid_runs():
|
|
|
55
57
|
out = resample_to_spacing(img, (1.0, 1.0, 1.0))
|
|
56
58
|
assert tuple(out.GetSize()) == (8, 8, 8)
|
|
57
59
|
assert out.GetSpacing() == pytest.approx((1.0, 1.0, 1.0))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_fit_spacing_keeps_target_when_under_limit():
|
|
63
|
+
orig_size = (64, 256, 256)
|
|
64
|
+
orig_spacing = (0.15, 0.117188, 0.117188)
|
|
65
|
+
target = [0.03, 0.03, 0.03]
|
|
66
|
+
n_vox = int(np.prod(resampled_grid_size(orig_size, orig_spacing, target)))
|
|
67
|
+
assert n_vox == 320_000_000
|
|
68
|
+
fitted = fit_spacing_to_max_voxels(orig_size, orig_spacing, target)
|
|
69
|
+
assert fitted == pytest.approx(target)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_fit_spacing_coarsens_to_voxel_budget():
|
|
73
|
+
orig_size = (512, 512, 258)
|
|
74
|
+
orig_spacing = (0.65, 0.65, 1.0)
|
|
75
|
+
target = [0.03, 0.03, 0.03]
|
|
76
|
+
fitted = fit_spacing_to_max_voxels(orig_size, orig_spacing, target)
|
|
77
|
+
assert fitted is not None
|
|
78
|
+
n_vox = int(np.prod(resampled_grid_size(orig_size, orig_spacing, fitted)))
|
|
79
|
+
assert n_vox <= DEFAULT_MAX_RESAMPLE_VOXELS
|
|
80
|
+
assert all(f <= s for f, s in zip(fitted, orig_spacing))
|
|
81
|
+
assert any(f < s for f, s in zip(fitted, orig_spacing))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_fit_spacing_returns_none_if_original_already_over_budget():
|
|
85
|
+
fitted = fit_spacing_to_max_voxels(
|
|
86
|
+
(100, 100, 100),
|
|
87
|
+
(1.0, 1.0, 1.0),
|
|
88
|
+
(0.01, 0.01, 0.01),
|
|
89
|
+
max_voxels=1_000,
|
|
90
|
+
)
|
|
91
|
+
assert fitted is None
|
|
@@ -16,7 +16,10 @@ from seqseg.pipeline.train import (
|
|
|
16
16
|
_dir_for_sampler,
|
|
17
17
|
_harden_sampler_vtk,
|
|
18
18
|
_normalize_img_ext,
|
|
19
|
+
_reduction_or_nan,
|
|
19
20
|
_resolve_img_ext,
|
|
21
|
+
_safe_add_image_stats,
|
|
22
|
+
_safe_add_local_stats,
|
|
20
23
|
dataset_id_from_name,
|
|
21
24
|
expected_nnunet_dataset_name,
|
|
22
25
|
prepare_training_dataset,
|
|
@@ -323,6 +326,58 @@ def test_harden_sampler_vtk_patches_collect_arrays():
|
|
|
323
326
|
|
|
324
327
|
_harden_sampler_vtk()
|
|
325
328
|
assert samp_fn.collect_arrays is collect_arrays
|
|
329
|
+
assert samp_fn.add_local_stats is _safe_add_local_stats
|
|
330
|
+
assert samp_fn.add_image_stats is _safe_add_image_stats
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_safe_add_local_stats_empty_blood_does_not_raise():
|
|
334
|
+
stats, extra = _safe_add_local_stats(
|
|
335
|
+
{},
|
|
336
|
+
np.array([1.0, 2.0, 3.0]),
|
|
337
|
+
0.0,
|
|
338
|
+
np.array([]),
|
|
339
|
+
np.zeros((2, 2, 2)),
|
|
340
|
+
[1],
|
|
341
|
+
None,
|
|
342
|
+
np.ones((2, 2, 2)),
|
|
343
|
+
0,
|
|
344
|
+
)
|
|
345
|
+
assert extra == 0
|
|
346
|
+
assert np.isnan(stats["BLOOD_MIN"])
|
|
347
|
+
assert np.isnan(stats["BLOOD_MEAN"])
|
|
348
|
+
assert stats["GT_MIN"] == 0.0
|
|
349
|
+
assert stats["POINT_CENT"] == [1.0, 2.0, 3.0]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_safe_add_local_stats_empty_largest_component():
|
|
353
|
+
sitk = pytest.importorskip("SimpleITK")
|
|
354
|
+
empty = sitk.Image(2, 2, 2, sitk.sitkUInt8)
|
|
355
|
+
im = np.arange(8, dtype=float).reshape(2, 2, 2)
|
|
356
|
+
stats, extra = _safe_add_local_stats(
|
|
357
|
+
{},
|
|
358
|
+
[0, 0, 0],
|
|
359
|
+
0.0,
|
|
360
|
+
im.ravel(),
|
|
361
|
+
np.ones((2, 2, 2)),
|
|
362
|
+
[1, 2],
|
|
363
|
+
empty,
|
|
364
|
+
im,
|
|
365
|
+
0,
|
|
366
|
+
)
|
|
367
|
+
assert extra == 1
|
|
368
|
+
assert np.isnan(stats["LARGEST_MIN"])
|
|
369
|
+
assert np.isnan(stats["LARGEST_MEAN"])
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def test_safe_add_image_stats_empty_volume():
|
|
373
|
+
stats = _safe_add_image_stats({}, np.array([]))
|
|
374
|
+
assert np.isnan(stats["IM_MIN"])
|
|
375
|
+
assert np.isnan(stats["IM_MAX"])
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_reduction_or_nan_nonempty():
|
|
379
|
+
assert _reduction_or_nan(np.array([3.0, 1.0, 2.0]), np.amin) == 1.0
|
|
380
|
+
assert np.isnan(_reduction_or_nan(np.array([]), np.amin))
|
|
326
381
|
|
|
327
382
|
|
|
328
383
|
def test_run_nnunet_training_requires_env(tmp_path, monkeypatch):
|
|
@@ -59,6 +59,7 @@ seqseg/pipeline/post.py
|
|
|
59
59
|
seqseg/pipeline/single_trace.py
|
|
60
60
|
seqseg/pipeline/train.py
|
|
61
61
|
seqseg/scripts/__init__.py
|
|
62
|
+
seqseg/scripts/prepare_casx_dataset.py
|
|
62
63
|
seqseg/scripts/taubin_smooth.py
|
|
63
64
|
seqseg/tests/conftest.py
|
|
64
65
|
seqseg/tests/test_api_entry.py
|
|
@@ -68,6 +69,7 @@ seqseg/tests/test_cli_helpers.py
|
|
|
68
69
|
seqseg/tests/test_datasets_paths.py
|
|
69
70
|
seqseg/tests/test_nnunet2.py
|
|
70
71
|
seqseg/tests/test_nnunet_device.py
|
|
72
|
+
seqseg/tests/test_prepare_casx_dataset.py
|
|
71
73
|
seqseg/tests/test_resample_spacing.py
|
|
72
74
|
seqseg/tests/test_segmentation.py
|
|
73
75
|
seqseg/tests/test_single_trace_staging.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|