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.
Files changed (81) hide show
  1. {seqseg-2.1.3 → seqseg-2.1.5}/PKG-INFO +1 -1
  2. {seqseg-2.1.3 → seqseg-2.1.5}/pyproject.toml +1 -1
  3. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/cli.py +11 -0
  4. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_test.yaml +3 -1
  5. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/centerline.py +34 -18
  6. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/sitk_functions.py +91 -5
  7. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/vtk_functions.py +22 -2
  8. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/classic.py +30 -9
  9. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/plus.py +27 -6
  10. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/train.py +137 -1
  11. seqseg-2.1.5/seqseg/scripts/prepare_casx_dataset.py +437 -0
  12. seqseg-2.1.5/seqseg/tests/test_centerline_gradient.py +49 -0
  13. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_cli_helpers.py +40 -0
  14. seqseg-2.1.5/seqseg/tests/test_prepare_casx_dataset.py +45 -0
  15. seqseg-2.1.5/seqseg/tests/test_resample_spacing.py +91 -0
  16. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_train_pipeline.py +114 -0
  17. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg.egg-info/PKG-INFO +1 -1
  18. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg.egg-info/SOURCES.txt +4 -0
  19. {seqseg-2.1.3 → seqseg-2.1.5}/LICENSE +0 -0
  20. {seqseg-2.1.3 → seqseg-2.1.5}/README.md +0 -0
  21. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/__init__.py +0 -0
  22. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/api.py +0 -0
  23. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/citations.py +0 -0
  24. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/__init__.py +0 -0
  25. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/aorta_tutorial.yaml +0 -0
  26. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global.yaml +0 -0
  27. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_aorta.yaml +0 -0
  28. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_cereb.yaml +0 -0
  29. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_coro.yaml +0 -0
  30. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_debug.yaml +0 -0
  31. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_default.yaml +0 -0
  32. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_pulm.yaml +0 -0
  33. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config/global_seg.yaml +0 -0
  34. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/config_models.py +0 -0
  35. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/__init__.py +0 -0
  36. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/assembly.py +0 -0
  37. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/capping.py +0 -0
  38. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/datasets.py +0 -0
  39. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/evaluation.py +0 -0
  40. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/initialization.py +0 -0
  41. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/local_assembly.py +0 -0
  42. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/nnunet.py +0 -0
  43. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/params.py +0 -0
  44. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/prediction.py +0 -0
  45. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/simvascular.py +0 -0
  46. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/simvascular_contour.py +0 -0
  47. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/sweep.py +0 -0
  48. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/tracing.py +0 -0
  49. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/tracing_functions.py +0 -0
  50. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/modules/vmtk_functions.py +0 -0
  51. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/parallel/old_auto_centerline.py +0 -0
  52. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/parallel/parallel_auto_centerline.py +0 -0
  53. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/parallel/parallel_auto_centerline2.py +0 -0
  54. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/parallel/parallel_auto_centerline3.py +0 -0
  55. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/parallel/parallel_auto_centerline4.py +0 -0
  56. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/__init__.py +0 -0
  57. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/config.py +0 -0
  58. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/directories.py +0 -0
  59. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/post.py +0 -0
  60. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/pipeline/single_trace.py +0 -0
  61. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/scripts/__init__.py +0 -0
  62. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/scripts/taubin_smooth.py +0 -0
  63. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/seqseg.py +0 -0
  64. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/seqseg_plus.py +0 -0
  65. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/conftest.py +0 -0
  66. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_api_entry.py +0 -0
  67. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_centerline_get_end_points.py +0 -0
  68. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_datasets_paths.py +0 -0
  69. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_nnunet2.py +0 -0
  70. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_nnunet_device.py +0 -0
  71. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_segmentation.py +0 -0
  72. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_single_trace_staging.py +0 -0
  73. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_start_seg.py +0 -0
  74. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_user_paths.py +0 -0
  75. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/tests/test_vtk.py +0 -0
  76. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg/user_paths.py +0 -0
  77. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg.egg-info/dependency_links.txt +0 -0
  78. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg.egg-info/entry_points.txt +0 -0
  79. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg.egg-info/requires.txt +0 -0
  80. {seqseg-2.1.3 → seqseg-2.1.5}/seqseg.egg-info/top_level.txt +0 -0
  81. {seqseg-2.1.3 → seqseg-2.1.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seqseg
3
- Version: 2.1.3
3
+ Version: 2.1.5
4
4
  Summary: A deep learning-based medical image blood vessel tracking and segmentation tool.
5
5
  Author-email: Numi Sveinsson Cepero <numi@berkeley.com>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "seqseg"
7
- version = "2.1.3"
7
+ version = "2.1.5"
8
8
  description = "A deep learning-based medical image blood vessel tracking and segmentation tool."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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
- CENT_MAX_SPACING: 0.3 # mm; max spacing per dim for final assembly (refine only)
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
- # Get gradient of distance map. ``np.gradient`` returns finite
1351
- # differences along the array (image-grid) axes, in units of "value
1352
- # per voxel". To use this to step a *physical* point we need a
1353
- # gradient in world-space units (value per mm, components along
1354
- # world axes). The conversion is:
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
- # where D is the SimpleITK direction matrix. Doing the conversion
1357
- # here once means every consumer (``backtracking_gradient``,
1358
- # ``interpolate_gradient``) gets a geometrically correct gradient
1359
- # without having to know about the image direction.
1360
- gradient = gradient_matrix(
1361
- sitk.GetArrayFromImage(output).transpose(2, 1, 0))
1362
- inv_spacing = 1.0 / np.asarray(segmentation.GetSpacing(),
1363
- dtype=np.float64)
1364
- direction_matrix = np.asarray(segmentation.GetDirection(),
1365
- dtype=np.float64).reshape(3, 3)
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
- def resample_to_spacing(image, new_spacing, is_label=False):
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
- new_size = np.maximum(
72
- 1,
73
- np.rint(orig_size * (orig_spacing / target_spacing)).astype(np.int64)
74
- ).tolist()
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().SetScalars(
1288
- n2v(np.zeros(v2n(ref_im.GetPointData().GetScalars()).shape)))
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
- print(
179
- f"Resampling final assembly to spacing <= "
180
- f"{float(cent_max_spacing)} mm: {target_spacing}"
178
+ fitted = sf.fit_spacing_to_max_voxels(
179
+ assembly.GetSize(),
180
+ assembly.GetSpacing(),
181
+ target_spacing,
181
182
  )
182
- assembly = sf.resample_to_spacing(
183
- assembly, target_spacing, is_label=False
184
- )
185
- if write_samples:
186
- n_udpates = sf.resample_to_spacing(
187
- n_udpates, target_spacing, is_label=True
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
- print(
210
- f"Resampling final assembly to spacing <= "
211
- f"{float(cent_max_spacing)} mm: {target_spacing}"
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"]