seqseg 2.1.3__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.3 → seqseg-2.1.5}/PKG-INFO +1 -1
- {seqseg-2.1.3 → seqseg-2.1.5}/pyproject.toml +1 -1
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/cli.py +11 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_test.yaml +3 -1
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/centerline.py +34 -18
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/sitk_functions.py +91 -5
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/vtk_functions.py +22 -2
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/classic.py +30 -9
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/plus.py +27 -6
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/train.py +137 -1
- seqseg-2.1.5/seqseg/scripts/prepare_casx_dataset.py +437 -0
- seqseg-2.1.5/seqseg/tests/test_centerline_gradient.py +49 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_cli_helpers.py +40 -0
- seqseg-2.1.5/seqseg/tests/test_prepare_casx_dataset.py +45 -0
- seqseg-2.1.5/seqseg/tests/test_resample_spacing.py +91 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_train_pipeline.py +114 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg.egg-info/PKG-INFO +1 -1
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg.egg-info/SOURCES.txt +4 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/LICENSE +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/README.md +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/__init__.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/api.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/citations.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/__init__.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/aorta_tutorial.yaml +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global.yaml +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_aorta.yaml +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_cereb.yaml +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_coro.yaml +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_debug.yaml +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_default.yaml +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_pulm.yaml +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_seg.yaml +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config_models.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/__init__.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/assembly.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/capping.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/datasets.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/evaluation.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/initialization.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/local_assembly.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/nnunet.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/params.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/prediction.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/simvascular.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/simvascular_contour.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/sweep.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/tracing.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/tracing_functions.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/vmtk_functions.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/parallel/old_auto_centerline.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/parallel/parallel_auto_centerline.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/parallel/parallel_auto_centerline2.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/parallel/parallel_auto_centerline3.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/parallel/parallel_auto_centerline4.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/__init__.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/config.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/directories.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/post.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/single_trace.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/scripts/__init__.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/scripts/taubin_smooth.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/seqseg.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/seqseg_plus.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/conftest.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_api_entry.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_centerline_get_end_points.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_datasets_paths.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_nnunet2.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_nnunet_device.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_segmentation.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_single_trace_staging.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_start_seg.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_user_paths.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_vtk.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/user_paths.py +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg.egg-info/dependency_links.txt +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg.egg-info/entry_points.txt +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg.egg-info/requires.txt +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/seqseg.egg-info/top_level.txt +0 -0
- {seqseg-2.1.3 → seqseg-2.1.5}/setup.cfg +0 -0
|
@@ -677,7 +677,18 @@ def _cmd_train_prepare(ns: argparse.Namespace) -> None:
|
|
|
677
677
|
print(str(e), file=sys.stderr)
|
|
678
678
|
sys.exit(1)
|
|
679
679
|
except Exception as e: # noqa: BLE001
|
|
680
|
+
import traceback
|
|
681
|
+
|
|
682
|
+
traceback.print_exc()
|
|
680
683
|
print(f"seqseg train prepare failed: {e}", file=sys.stderr)
|
|
684
|
+
if "GetDataType" in str(e):
|
|
685
|
+
print(
|
|
686
|
+
"A VTK array was None while reading centerlines/ (or "
|
|
687
|
+
"rasterizing surfaces/). Centerline .vtp files need points "
|
|
688
|
+
"and MaximumInscribedSphereRadius (VMTK/SimVascular). "
|
|
689
|
+
"Update SeqSeg and re-run with --num-cores 1.",
|
|
690
|
+
file=sys.stderr,
|
|
691
|
+
)
|
|
681
692
|
sys.exit(1)
|
|
682
693
|
|
|
683
694
|
print("\nPrepare complete.")
|
|
@@ -33,7 +33,9 @@ USE_BUFFER: True # buffer the assembly (so not to mess with retrace prevention)
|
|
|
33
33
|
BUFFER_N: 20 # add to assembly steps buff_N steps ago
|
|
34
34
|
WEIGHTED_ASSEMBLY: True # if assembly should be weighted
|
|
35
35
|
WEIGHT_TYPE: gaussian # type of weighting for assembly
|
|
36
|
-
|
|
36
|
+
# Disabled for CI: the test CT is 512x512x258 (~0.65 mm). Refining the full
|
|
37
|
+
# assembly to 0.3 mm is ~1e9 voxels and OOMs GitHub-hosted runners (exit 137).
|
|
38
|
+
CENT_MAX_SPACING: null
|
|
37
39
|
|
|
38
40
|
FORCEFUL_SIDEBRANCH: False
|
|
39
41
|
FORCEFUL_SIDEBRANCH_MAGN_RADIUS: 1.1
|
|
@@ -598,6 +598,25 @@ def gradient_matrix(x):
|
|
|
598
598
|
return gradients
|
|
599
599
|
|
|
600
600
|
|
|
601
|
+
def world_frame_gradient(grid_values, spacing, direction):
|
|
602
|
+
"""
|
|
603
|
+
Convert a 3-D scalar field sampled on the image grid to a world-space
|
|
604
|
+
gradient (value per mm, components along the image direction axes).
|
|
605
|
+
|
|
606
|
+
``np.gradient`` returns finite differences along array axes (per voxel).
|
|
607
|
+
Physical conversion is ``grad_world = D @ diag(1/spacing) @ grad_grid``.
|
|
608
|
+
Computation is in float64 so ITK FastMarching sentinels (~1e38 in
|
|
609
|
+
float32) do not overflow when scaled by ``1/spacing``.
|
|
610
|
+
"""
|
|
611
|
+
values = np.asarray(grid_values, dtype=np.float64)
|
|
612
|
+
gradient = np.stack(np.gradient(values), axis=0)
|
|
613
|
+
spacing = np.asarray(spacing, dtype=np.float64).reshape(3)
|
|
614
|
+
inv_spacing = 1.0 / np.maximum(spacing, np.finfo(np.float64).tiny)
|
|
615
|
+
direction_matrix = np.asarray(direction, dtype=np.float64).reshape(3, 3)
|
|
616
|
+
gradient = gradient * inv_spacing[:, None, None, None]
|
|
617
|
+
return np.tensordot(direction_matrix, gradient, axes=([1], [0]))
|
|
618
|
+
|
|
619
|
+
|
|
601
620
|
def hessian_matrix(x):
|
|
602
621
|
"""
|
|
603
622
|
Calculate the hessian matrix with finite differences
|
|
@@ -1347,25 +1366,22 @@ def calc_centerline_fmm(segmentation, seed=None, targets=None,
|
|
|
1347
1366
|
os.path.join(out_dir, 'masked_out_fmm.mha'))
|
|
1348
1367
|
sitk.WriteImage(segmentation,
|
|
1349
1368
|
os.path.join(out_dir, 'segmentation_from_fmm.mha'))
|
|
1350
|
-
#
|
|
1351
|
-
#
|
|
1352
|
-
#
|
|
1353
|
-
#
|
|
1354
|
-
#
|
|
1369
|
+
# Gradient of the *masked* arrival-time map. Unreached FastMarching
|
|
1370
|
+
# voxels keep ITK's float32 LargeValue (~1e38); differentiating that
|
|
1371
|
+
# field and scaling by 1/spacing overflows float32 (CI RuntimeWarning)
|
|
1372
|
+
# and yields inf/NaN steps. Mask + clip keeps the gradient inside the
|
|
1373
|
+
# vessel and finite. Conversion to world mm^-1 is:
|
|
1355
1374
|
# grad_world = D @ diag(1/spacing) @ grad_grid
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
gradient = gradient * inv_spacing[:, None, None, None]
|
|
1367
|
-
gradient = np.tensordot(direction_matrix, gradient,
|
|
1368
|
-
axes=([1], [0]))
|
|
1375
|
+
arrival = np.clip(
|
|
1376
|
+
np.asarray(
|
|
1377
|
+
sitk.GetArrayFromImage(output_mask).transpose(2, 1, 0),
|
|
1378
|
+
dtype=np.float64,
|
|
1379
|
+
),
|
|
1380
|
+
-1.0e6,
|
|
1381
|
+
1.0e6,
|
|
1382
|
+
)
|
|
1383
|
+
gradient = world_frame_gradient(
|
|
1384
|
+
arrival, segmentation.GetSpacing(), segmentation.GetDirection())
|
|
1369
1385
|
if verbose:
|
|
1370
1386
|
# Calculate and print gradient magnitude statistics
|
|
1371
1387
|
gradient_magnitude = np.sqrt(np.sum(gradient**2, axis=0))
|
|
@@ -55,7 +55,85 @@ def copy_settings(img, ref_img):
|
|
|
55
55
|
return img
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
|
|
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
|
+
|
|
63
|
+
|
|
64
|
+
def resampled_grid_size(orig_size, orig_spacing, new_spacing):
|
|
65
|
+
"""Voxel size that :func:`resample_to_spacing` would produce."""
|
|
66
|
+
orig_size = np.asarray(orig_size, dtype=np.int64)
|
|
67
|
+
orig_spacing = np.asarray(orig_spacing, dtype=np.float64)
|
|
68
|
+
target_spacing = np.asarray(new_spacing, dtype=np.float64)
|
|
69
|
+
if np.any(target_spacing <= 0):
|
|
70
|
+
raise ValueError(f"new_spacing must be > 0, got {target_spacing.tolist()}")
|
|
71
|
+
return np.maximum(
|
|
72
|
+
1,
|
|
73
|
+
np.rint(orig_size * (orig_spacing / target_spacing)).astype(np.int64),
|
|
74
|
+
)
|
|
75
|
+
|
|
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
|
+
|
|
135
|
+
def resample_to_spacing(image, new_spacing, is_label=False,
|
|
136
|
+
max_voxels=DEFAULT_MAX_RESAMPLE_VOXELS):
|
|
59
137
|
"""
|
|
60
138
|
Resample a SITK image to a target spacing while preserving physical extent.
|
|
61
139
|
|
|
@@ -63,15 +141,23 @@ def resample_to_spacing(image, new_spacing, is_label=False):
|
|
|
63
141
|
image (sitk.Image): Input image.
|
|
64
142
|
new_spacing (iterable): Target spacing [sx, sy, sz].
|
|
65
143
|
is_label (bool): Use nearest-neighbor interpolation for labels.
|
|
144
|
+
max_voxels (int | None): If set, raise ``ValueError`` when the
|
|
145
|
+
output grid would exceed this many voxels instead of allocating.
|
|
66
146
|
"""
|
|
67
147
|
orig_size = np.array(image.GetSize(), dtype=np.int64)
|
|
68
148
|
orig_spacing = np.array(image.GetSpacing(), dtype=np.float64)
|
|
69
149
|
target_spacing = np.array(new_spacing, dtype=np.float64)
|
|
70
150
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
151
|
+
new_size_arr = resampled_grid_size(orig_size, orig_spacing, target_spacing)
|
|
152
|
+
n_vox = int(np.prod(new_size_arr))
|
|
153
|
+
if max_voxels is not None and n_vox > int(max_voxels):
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"Resampling to spacing {target_spacing.tolist()} would create "
|
|
156
|
+
f"{tuple(int(s) for s in new_size_arr)} voxels ({n_vox:,}), "
|
|
157
|
+
f"exceeding max_voxels={int(max_voxels):,}. Check CENT_MAX_SPACING "
|
|
158
|
+
f"and -unit (mm vs cm) before refining a full-volume assembly."
|
|
159
|
+
)
|
|
160
|
+
new_size = new_size_arr.tolist()
|
|
75
161
|
|
|
76
162
|
resample = sitk.ResampleImageFilter()
|
|
77
163
|
resample.SetOutputSpacing(target_spacing.tolist())
|
|
@@ -87,10 +87,20 @@ class ClosestPoints:
|
|
|
87
87
|
|
|
88
88
|
|
|
89
89
|
def collect_arrays(output):
|
|
90
|
+
"""Convert VTK field/point/cell data arrays to numpy.
|
|
91
|
+
|
|
92
|
+
Skip unnamed arrays and non-numeric slots (``GetArray(i)`` is None for
|
|
93
|
+
string/abstract arrays). ``vtk_to_numpy(None)`` raises
|
|
94
|
+
``'NoneType' object has no attribute 'GetDataType'``.
|
|
95
|
+
"""
|
|
90
96
|
res = {}
|
|
97
|
+
if output is None:
|
|
98
|
+
return res
|
|
91
99
|
for i in range(output.GetNumberOfArrays()):
|
|
92
100
|
name = output.GetArrayName(i)
|
|
93
101
|
data = output.GetArray(i)
|
|
102
|
+
if name is None or data is None:
|
|
103
|
+
continue
|
|
94
104
|
res[name] = v2n(data)
|
|
95
105
|
return res
|
|
96
106
|
|
|
@@ -1284,8 +1294,18 @@ def convertPolyDataToImageData(poly, ref_im):
|
|
|
1284
1294
|
output: resulted vtkImageData
|
|
1285
1295
|
"""
|
|
1286
1296
|
|
|
1287
|
-
ref_im.GetPointData().
|
|
1288
|
-
|
|
1297
|
+
sc = ref_im.GetPointData().GetScalars()
|
|
1298
|
+
if sc is None:
|
|
1299
|
+
dims = ref_im.GetDimensions()
|
|
1300
|
+
n = int(dims[0]) * int(dims[1]) * int(dims[2])
|
|
1301
|
+
if n <= 0:
|
|
1302
|
+
raise RuntimeError(
|
|
1303
|
+
f"VTK image has no scalars and empty dimensions {dims}"
|
|
1304
|
+
)
|
|
1305
|
+
shape = n
|
|
1306
|
+
else:
|
|
1307
|
+
shape = v2n(sc).shape
|
|
1308
|
+
ref_im.GetPointData().SetScalars(n2v(np.zeros(shape)))
|
|
1289
1309
|
ply2im = vtk.vtkPolyDataToImageStencil()
|
|
1290
1310
|
ply2im.SetTolerance(0.05)
|
|
1291
1311
|
ply2im.SetInputData(poly)
|
|
@@ -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
|
|
|
@@ -31,9 +33,143 @@ def _require_sampler():
|
|
|
31
33
|
"or:\n"
|
|
32
34
|
" pip install vascular-segment-sampler"
|
|
33
35
|
) from e
|
|
36
|
+
_harden_sampler_vtk()
|
|
34
37
|
return extract_patches, write_nnunet_dataset
|
|
35
38
|
|
|
36
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
|
+
|
|
109
|
+
def _harden_sampler_vtk() -> None:
|
|
110
|
+
"""Patch sampler helpers that break on common SeqSeg datasets.
|
|
111
|
+
|
|
112
|
+
Centerline .vtp files from SimVascular/Slicer often include string/abstract
|
|
113
|
+
arrays. ``GetArray(i)`` is None for those, which became
|
|
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``.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
import vascular_segment_sampler.sampling.extract as extract_mod
|
|
121
|
+
import vascular_segment_sampler.sampling.functions as samp_fn
|
|
122
|
+
import vascular_segment_sampler.vtk_functions as vf
|
|
123
|
+
from seqseg.modules.vtk_functions import (
|
|
124
|
+
collect_arrays,
|
|
125
|
+
convertPolyDataToImageData,
|
|
126
|
+
)
|
|
127
|
+
except ImportError:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
vf.collect_arrays = collect_arrays
|
|
131
|
+
samp_fn.collect_arrays = collect_arrays
|
|
132
|
+
vf.convertPolyDataToImageData = convertPolyDataToImageData
|
|
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
|
+
|
|
141
|
+
orig_sort = getattr(samp_fn, "sort_centerline", None)
|
|
142
|
+
if orig_sort is not None and not getattr(orig_sort, "_seqseg_hardened", False):
|
|
143
|
+
|
|
144
|
+
def _sort_centerline(centerline, *args, **kwargs):
|
|
145
|
+
if centerline is None or centerline.GetNumberOfPoints() == 0:
|
|
146
|
+
raise RuntimeError(
|
|
147
|
+
"Centerline has no points. Each case needs a non-empty .vtp "
|
|
148
|
+
"in centerlines/ (VMTK/SimVascular)."
|
|
149
|
+
)
|
|
150
|
+
pts = centerline.GetPoints()
|
|
151
|
+
if pts is None or pts.GetData() is None:
|
|
152
|
+
raise RuntimeError(
|
|
153
|
+
"Centerline vtkPoints are empty. Check centerlines/*.vtp."
|
|
154
|
+
)
|
|
155
|
+
try:
|
|
156
|
+
return orig_sort(centerline, *args, **kwargs)
|
|
157
|
+
except KeyError as e:
|
|
158
|
+
key = str(e).strip("'\"")
|
|
159
|
+
if key in ("f", "MaximumInscribedSphereRadius", "Radius"):
|
|
160
|
+
raise RuntimeError(
|
|
161
|
+
"Centerline has no MaximumInscribedSphereRadius (or "
|
|
162
|
+
"Radius) point-data array. Export VMTK/SimVascular "
|
|
163
|
+
"centerlines."
|
|
164
|
+
) from e
|
|
165
|
+
raise
|
|
166
|
+
|
|
167
|
+
_sort_centerline._seqseg_hardened = True
|
|
168
|
+
samp_fn.sort_centerline = _sort_centerline
|
|
169
|
+
|
|
170
|
+
extract_mod.sort_centerline = samp_fn.sort_centerline
|
|
171
|
+
|
|
172
|
+
|
|
37
173
|
def _parse_modalities(modality: Optional[str]) -> List[str]:
|
|
38
174
|
if not modality:
|
|
39
175
|
return ["CT"]
|