seqseg 2.1.3__tar.gz → 2.1.4__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 (79) hide show
  1. {seqseg-2.1.3 → seqseg-2.1.4}/PKG-INFO +1 -1
  2. {seqseg-2.1.3 → seqseg-2.1.4}/pyproject.toml +1 -1
  3. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/cli.py +11 -0
  4. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/config/global_test.yaml +3 -1
  5. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/centerline.py +34 -18
  6. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/sitk_functions.py +33 -5
  7. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/vtk_functions.py +22 -2
  8. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/pipeline/train.py +55 -0
  9. seqseg-2.1.4/seqseg/tests/test_centerline_gradient.py +49 -0
  10. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/tests/test_cli_helpers.py +40 -0
  11. seqseg-2.1.4/seqseg/tests/test_resample_spacing.py +57 -0
  12. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/tests/test_train_pipeline.py +59 -0
  13. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg.egg-info/PKG-INFO +1 -1
  14. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg.egg-info/SOURCES.txt +2 -0
  15. {seqseg-2.1.3 → seqseg-2.1.4}/LICENSE +0 -0
  16. {seqseg-2.1.3 → seqseg-2.1.4}/README.md +0 -0
  17. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/__init__.py +0 -0
  18. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/api.py +0 -0
  19. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/citations.py +0 -0
  20. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/config/__init__.py +0 -0
  21. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/config/aorta_tutorial.yaml +0 -0
  22. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/config/global.yaml +0 -0
  23. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/config/global_aorta.yaml +0 -0
  24. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/config/global_cereb.yaml +0 -0
  25. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/config/global_coro.yaml +0 -0
  26. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/config/global_debug.yaml +0 -0
  27. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/config/global_default.yaml +0 -0
  28. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/config/global_pulm.yaml +0 -0
  29. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/config/global_seg.yaml +0 -0
  30. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/config_models.py +0 -0
  31. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/__init__.py +0 -0
  32. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/assembly.py +0 -0
  33. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/capping.py +0 -0
  34. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/datasets.py +0 -0
  35. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/evaluation.py +0 -0
  36. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/initialization.py +0 -0
  37. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/local_assembly.py +0 -0
  38. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/nnunet.py +0 -0
  39. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/params.py +0 -0
  40. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/prediction.py +0 -0
  41. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/simvascular.py +0 -0
  42. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/simvascular_contour.py +0 -0
  43. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/sweep.py +0 -0
  44. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/tracing.py +0 -0
  45. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/tracing_functions.py +0 -0
  46. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/modules/vmtk_functions.py +0 -0
  47. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/parallel/old_auto_centerline.py +0 -0
  48. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/parallel/parallel_auto_centerline.py +0 -0
  49. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/parallel/parallel_auto_centerline2.py +0 -0
  50. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/parallel/parallel_auto_centerline3.py +0 -0
  51. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/parallel/parallel_auto_centerline4.py +0 -0
  52. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/pipeline/__init__.py +0 -0
  53. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/pipeline/classic.py +0 -0
  54. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/pipeline/config.py +0 -0
  55. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/pipeline/directories.py +0 -0
  56. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/pipeline/plus.py +0 -0
  57. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/pipeline/post.py +0 -0
  58. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/pipeline/single_trace.py +0 -0
  59. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/scripts/__init__.py +0 -0
  60. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/scripts/taubin_smooth.py +0 -0
  61. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/seqseg.py +0 -0
  62. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/seqseg_plus.py +0 -0
  63. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/tests/conftest.py +0 -0
  64. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/tests/test_api_entry.py +0 -0
  65. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/tests/test_centerline_get_end_points.py +0 -0
  66. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/tests/test_datasets_paths.py +0 -0
  67. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/tests/test_nnunet2.py +0 -0
  68. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/tests/test_nnunet_device.py +0 -0
  69. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/tests/test_segmentation.py +0 -0
  70. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/tests/test_single_trace_staging.py +0 -0
  71. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/tests/test_start_seg.py +0 -0
  72. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/tests/test_user_paths.py +0 -0
  73. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/tests/test_vtk.py +0 -0
  74. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg/user_paths.py +0 -0
  75. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg.egg-info/dependency_links.txt +0 -0
  76. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg.egg-info/entry_points.txt +0 -0
  77. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg.egg-info/requires.txt +0 -0
  78. {seqseg-2.1.3 → seqseg-2.1.4}/seqseg.egg-info/top_level.txt +0 -0
  79. {seqseg-2.1.3 → seqseg-2.1.4}/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.4
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.4"
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,27 @@ 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
+ # Refuse pathological upsampling (e.g. CENT_MAX_SPACING in mm applied with
59
+ # ``-unit cm``) before SimpleITK tries to allocate a multi-billion-voxel grid
60
+ # and the process is SIGKILL'd (exit 137) on CI runners.
61
+ DEFAULT_MAX_RESAMPLE_VOXELS = 200_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 resample_to_spacing(image, new_spacing, is_label=False,
78
+ max_voxels=DEFAULT_MAX_RESAMPLE_VOXELS):
59
79
  """
60
80
  Resample a SITK image to a target spacing while preserving physical extent.
61
81
 
@@ -63,15 +83,23 @@ def resample_to_spacing(image, new_spacing, is_label=False):
63
83
  image (sitk.Image): Input image.
64
84
  new_spacing (iterable): Target spacing [sx, sy, sz].
65
85
  is_label (bool): Use nearest-neighbor interpolation for labels.
86
+ max_voxels (int | None): If set, raise ``ValueError`` when the
87
+ output grid would exceed this many voxels instead of allocating.
66
88
  """
67
89
  orig_size = np.array(image.GetSize(), dtype=np.int64)
68
90
  orig_spacing = np.array(image.GetSpacing(), dtype=np.float64)
69
91
  target_spacing = np.array(new_spacing, dtype=np.float64)
70
92
 
71
- new_size = np.maximum(
72
- 1,
73
- np.rint(orig_size * (orig_spacing / target_spacing)).astype(np.int64)
74
- ).tolist()
93
+ new_size_arr = resampled_grid_size(orig_size, orig_spacing, target_spacing)
94
+ n_vox = int(np.prod(new_size_arr))
95
+ if max_voxels is not None and n_vox > int(max_voxels):
96
+ raise ValueError(
97
+ f"Resampling to spacing {target_spacing.tolist()} would create "
98
+ f"{tuple(int(s) for s in new_size_arr)} voxels ({n_vox:,}), "
99
+ f"exceeding max_voxels={int(max_voxels):,}. Check CENT_MAX_SPACING "
100
+ f"and -unit (mm vs cm) before refining a full-volume assembly."
101
+ )
102
+ new_size = new_size_arr.tolist()
75
103
 
76
104
  resample = sitk.ResampleImageFilter()
77
105
  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)
@@ -31,9 +31,64 @@ def _require_sampler():
31
31
  "or:\n"
32
32
  " pip install vascular-segment-sampler"
33
33
  ) from e
34
+ _harden_sampler_vtk()
34
35
  return extract_patches, write_nnunet_dataset
35
36
 
36
37
 
38
+ def _harden_sampler_vtk() -> None:
39
+ """Skip non-numeric VTK arrays the sampler used to pass to vtk_to_numpy.
40
+
41
+ Centerline .vtp files from SimVascular/Slicer often include string/abstract
42
+ arrays. ``GetArray(i)`` is None for those, which became
43
+ ``'NoneType' object has no attribute 'GetDataType'``.
44
+ """
45
+ try:
46
+ import vascular_segment_sampler.sampling.extract as extract_mod
47
+ import vascular_segment_sampler.sampling.functions as samp_fn
48
+ import vascular_segment_sampler.vtk_functions as vf
49
+ from seqseg.modules.vtk_functions import (
50
+ collect_arrays,
51
+ convertPolyDataToImageData,
52
+ )
53
+ except ImportError:
54
+ return
55
+
56
+ vf.collect_arrays = collect_arrays
57
+ samp_fn.collect_arrays = collect_arrays
58
+ vf.convertPolyDataToImageData = convertPolyDataToImageData
59
+
60
+ orig_sort = getattr(samp_fn, "sort_centerline", None)
61
+ if orig_sort is not None and not getattr(orig_sort, "_seqseg_hardened", False):
62
+
63
+ def _sort_centerline(centerline, *args, **kwargs):
64
+ if centerline is None or centerline.GetNumberOfPoints() == 0:
65
+ raise RuntimeError(
66
+ "Centerline has no points. Each case needs a non-empty .vtp "
67
+ "in centerlines/ (VMTK/SimVascular)."
68
+ )
69
+ pts = centerline.GetPoints()
70
+ if pts is None or pts.GetData() is None:
71
+ raise RuntimeError(
72
+ "Centerline vtkPoints are empty. Check centerlines/*.vtp."
73
+ )
74
+ try:
75
+ return orig_sort(centerline, *args, **kwargs)
76
+ except KeyError as e:
77
+ key = str(e).strip("'\"")
78
+ if key in ("f", "MaximumInscribedSphereRadius", "Radius"):
79
+ raise RuntimeError(
80
+ "Centerline has no MaximumInscribedSphereRadius (or "
81
+ "Radius) point-data array. Export VMTK/SimVascular "
82
+ "centerlines."
83
+ ) from e
84
+ raise
85
+
86
+ _sort_centerline._seqseg_hardened = True
87
+ samp_fn.sort_centerline = _sort_centerline
88
+
89
+ extract_mod.sort_centerline = samp_fn.sort_centerline
90
+
91
+
37
92
  def _parse_modalities(modality: Optional[str]) -> List[str]:
38
93
  if not modality:
39
94
  return ["CT"]
@@ -0,0 +1,49 @@
1
+ """World-frame FMM gradient conversion (overflow / direction)."""
2
+
3
+ import numpy as np
4
+
5
+ from seqseg.modules.centerline import world_frame_gradient
6
+
7
+
8
+ def test_world_frame_gradient_identity_spacing():
9
+ # f(x,y,z) = 2x + 3y + 4z on a unit grid -> grad = (2, 3, 4)
10
+ x = np.arange(5)[:, None, None]
11
+ y = np.arange(5)[None, :, None]
12
+ z = np.arange(5)[None, None, :]
13
+ field = 2.0 * x + 3.0 * y + 4.0 * z
14
+ identity = (1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0)
15
+ grad = world_frame_gradient(field, (1.0, 1.0, 1.0), identity)
16
+ interior = grad[:, 1:-1, 1:-1, 1:-1]
17
+ np.testing.assert_allclose(interior[0], 2.0, atol=1e-12)
18
+ np.testing.assert_allclose(interior[1], 3.0, atol=1e-12)
19
+ np.testing.assert_allclose(interior[2], 4.0, atol=1e-12)
20
+
21
+
22
+ def test_world_frame_gradient_scales_by_inv_spacing():
23
+ x = np.arange(5, dtype=np.float64)[:, None, None]
24
+ field = np.broadcast_to(x, (5, 5, 5)).copy()
25
+ identity = (1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0)
26
+ # Two voxels of +1 in x, spacing 2 mm -> 0.5 / mm
27
+ grad = world_frame_gradient(field, (2.0, 2.0, 2.0), identity)
28
+ np.testing.assert_allclose(grad[0, 1:-1, :, :], 0.5, atol=1e-12)
29
+
30
+
31
+ def test_world_frame_gradient_float32_largevalue_does_not_overflow():
32
+ """ITK FastMarching unreached voxels are ~1e38 in float32."""
33
+ field = np.zeros((6, 6, 6), dtype=np.float32)
34
+ field[2:4, 2:4, 2:4] = 10.0
35
+ field[0, 0, 0] = np.float32(1.7e38)
36
+ identity = (1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0)
37
+ with np.errstate(over="raise"):
38
+ grad = world_frame_gradient(field, (0.65, 0.65, 1.0), identity)
39
+ assert np.isfinite(grad).all()
40
+
41
+
42
+ def test_world_frame_gradient_applies_direction_flip():
43
+ x = np.arange(5, dtype=np.float64)[:, None, None]
44
+ field = np.broadcast_to(x, (5, 5, 5)).copy()
45
+ flip_x = (-1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0)
46
+ grad = world_frame_gradient(field, (1.0, 1.0, 1.0), flip_x)
47
+ np.testing.assert_allclose(grad[0, 1:-1, :, :], -1.0, atol=1e-12)
48
+ np.testing.assert_allclose(grad[1], 0.0, atol=1e-12)
49
+ np.testing.assert_allclose(grad[2], 0.0, atol=1e-12)
@@ -362,6 +362,46 @@ def test_cmd_train_prepare_missing_dep(tmp_path, capsys):
362
362
  assert "missing" in capsys.readouterr().err
363
363
 
364
364
 
365
+ def test_cmd_train_prepare_getdatatype_hint(tmp_path, capsys):
366
+ ns = argparse.Namespace(
367
+ data_dir=str(tmp_path),
368
+ outdir=str(tmp_path / "out"),
369
+ name="MYDATA",
370
+ dataset_number=999,
371
+ modality="CT",
372
+ config_name="global",
373
+ nnunet_raw=None,
374
+ perc_dataset=1.0,
375
+ num_cores=1,
376
+ start_from=0,
377
+ end_at=-1,
378
+ testing=False,
379
+ validation_prop=None,
380
+ max_samples=None,
381
+ truth_from_surface=False,
382
+ truth_target_spacing=None,
383
+ truth_regenerate=False,
384
+ skip_sample=False,
385
+ skip_convert=False,
386
+ also_test=False,
387
+ yes=False,
388
+ verbose=False,
389
+ img_ext=None,
390
+ )
391
+ with patch(
392
+ "seqseg.cli.prepare_training_dataset",
393
+ side_effect=AttributeError(
394
+ "'NoneType' object has no attribute 'GetDataType'"
395
+ ),
396
+ ):
397
+ with pytest.raises(SystemExit) as exc:
398
+ _cmd_train_prepare(ns)
399
+ assert exc.value.code == 1
400
+ err = capsys.readouterr().err
401
+ assert "GetDataType" in err
402
+ assert "centerlines" in err
403
+
404
+
365
405
  def test_cmd_train_nnunet_resolves_name(monkeypatch):
366
406
  called = {}
367
407
 
@@ -0,0 +1,57 @@
1
+ """Assembly spacing cap and resample size guards (CI OOM prevention)."""
2
+
3
+ import numpy as np
4
+ import pytest
5
+ import SimpleITK as sitk
6
+
7
+ from seqseg.config_models import AlgorithmConfig
8
+ from seqseg.modules.sitk_functions import (
9
+ capped_target_spacing,
10
+ resample_to_spacing,
11
+ resampled_grid_size,
12
+ )
13
+
14
+
15
+ def test_capped_target_spacing_mm_caps_coarse_dims():
16
+ target = capped_target_spacing((0.65, 0.65, 1.0), 0.3, "mm")
17
+ assert target == pytest.approx([0.3, 0.3, 0.3])
18
+
19
+
20
+ def test_capped_target_spacing_already_fine_returns_none():
21
+ assert capped_target_spacing((0.2, 0.2, 0.2), 0.3, "mm") is None
22
+
23
+
24
+ def test_capped_target_spacing_cm_is_tenfold_finer():
25
+ # Image spacing stored in mm, but -unit cm treats 0.3 mm as 0.03.
26
+ target = capped_target_spacing((0.65, 0.65, 1.0), 0.3, "cm")
27
+ assert target == pytest.approx([0.03, 0.03, 0.03])
28
+ n_vox = int(np.prod(resampled_grid_size(
29
+ (512, 512, 258), (0.65, 0.65, 1.0), target
30
+ )))
31
+ # ~1e9 voxels — this is what SIGKILL'd the macos CI job.
32
+ assert n_vox > 200_000_000
33
+
34
+
35
+ def test_resampled_grid_size_identity():
36
+ size = resampled_grid_size((10, 20, 30), (1.0, 1.0, 1.0), (1.0, 1.0, 1.0))
37
+ np.testing.assert_array_equal(size, [10, 20, 30])
38
+
39
+
40
+ def test_resample_to_spacing_refuses_huge_grid():
41
+ img = sitk.Image(8, 8, 8, sitk.sitkUInt8)
42
+ img.SetSpacing((1.0, 1.0, 1.0))
43
+ with pytest.raises(ValueError, match="max_voxels"):
44
+ resample_to_spacing(img, (0.01, 0.01, 0.01), max_voxels=1_000)
45
+
46
+
47
+ def test_global_test_config_skips_assembly_upsample():
48
+ cfg = AlgorithmConfig.from_name("global_test")
49
+ assert cfg.get("CENT_MAX_SPACING") is None
50
+
51
+
52
+ def test_resample_to_spacing_small_grid_runs():
53
+ img = sitk.Image(4, 4, 4, sitk.sitkFloat32)
54
+ img.SetSpacing((2.0, 2.0, 2.0))
55
+ out = resample_to_spacing(img, (1.0, 1.0, 1.0))
56
+ assert tuple(out.GetSize()) == (8, 8, 8)
57
+ assert out.GetSpacing() == pytest.approx((1.0, 1.0, 1.0))
@@ -8,10 +8,13 @@ import os
8
8
 
9
9
  from pathlib import Path
10
10
 
11
+ import numpy as np
12
+
11
13
  from seqseg.pipeline.train import (
12
14
  TrainDependencyError,
13
15
  _detect_img_ext,
14
16
  _dir_for_sampler,
17
+ _harden_sampler_vtk,
15
18
  _normalize_img_ext,
16
19
  _resolve_img_ext,
17
20
  dataset_id_from_name,
@@ -266,6 +269,62 @@ def test_prepare_missing_stats_csv_without_patches_is_actionable(tmp_path):
266
269
  write.assert_not_called()
267
270
 
268
271
 
272
+ def test_collect_arrays_skips_string_arrays():
273
+ import vtk
274
+ from vtk.util.numpy_support import numpy_to_vtk
275
+
276
+ from seqseg.modules.vtk_functions import collect_arrays
277
+
278
+ pts = vtk.vtkPoints()
279
+ pts.InsertNextPoint(0.0, 0.0, 0.0)
280
+ poly = vtk.vtkPolyData()
281
+ poly.SetPoints(pts)
282
+
283
+ rad = numpy_to_vtk(np.array([1.5], dtype=np.float64))
284
+ rad.SetName("MaximumInscribedSphereRadius")
285
+ poly.GetPointData().AddArray(rad)
286
+
287
+ names = vtk.vtkStringArray()
288
+ names.SetName("ModelName")
289
+ names.InsertNextValue("aorta")
290
+ poly.GetPointData().AddArray(names)
291
+
292
+ data = collect_arrays(poly.GetPointData())
293
+ assert "MaximumInscribedSphereRadius" in data
294
+ assert "ModelName" not in data
295
+ assert float(data["MaximumInscribedSphereRadius"][0]) == 1.5
296
+
297
+
298
+ def test_convert_poly_to_image_without_scalars():
299
+ import vtk
300
+
301
+ from seqseg.modules.vtk_functions import convertPolyDataToImageData
302
+
303
+ sphere = vtk.vtkSphereSource()
304
+ sphere.SetCenter(4, 4, 4)
305
+ sphere.SetRadius(2.0)
306
+ sphere.Update()
307
+
308
+ ref = vtk.vtkImageData()
309
+ ref.SetDimensions(8, 8, 8)
310
+ ref.SetSpacing(1.0, 1.0, 1.0)
311
+ ref.SetOrigin(0, 0, 0)
312
+
313
+ out = convertPolyDataToImageData(sphere.GetOutput(), ref)
314
+ assert out.GetPointData().GetScalars() is not None
315
+ assert out.GetDimensions() == (8, 8, 8)
316
+
317
+
318
+ def test_harden_sampler_vtk_patches_collect_arrays():
319
+ pytest.importorskip("vascular_segment_sampler")
320
+ import vascular_segment_sampler.sampling.functions as samp_fn
321
+
322
+ from seqseg.modules.vtk_functions import collect_arrays
323
+
324
+ _harden_sampler_vtk()
325
+ assert samp_fn.collect_arrays is collect_arrays
326
+
327
+
269
328
  def test_run_nnunet_training_requires_env(tmp_path, monkeypatch):
270
329
  monkeypatch.setenv("SEQSEG_PATHS_FILE", str(tmp_path / "no_paths.yaml"))
271
330
  for key in ("nnUNet_raw", "nnUNet_preprocessed", "nnUNet_results"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seqseg
3
- Version: 2.1.3
3
+ Version: 2.1.4
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
@@ -63,10 +63,12 @@ seqseg/scripts/taubin_smooth.py
63
63
  seqseg/tests/conftest.py
64
64
  seqseg/tests/test_api_entry.py
65
65
  seqseg/tests/test_centerline_get_end_points.py
66
+ seqseg/tests/test_centerline_gradient.py
66
67
  seqseg/tests/test_cli_helpers.py
67
68
  seqseg/tests/test_datasets_paths.py
68
69
  seqseg/tests/test_nnunet2.py
69
70
  seqseg/tests/test_nnunet_device.py
71
+ seqseg/tests/test_resample_spacing.py
70
72
  seqseg/tests/test_segmentation.py
71
73
  seqseg/tests/test_single_trace_staging.py
72
74
  seqseg/tests/test_start_seg.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