seqseg 2.1.4__tar.gz → 2.1.6__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.4 → seqseg-2.1.6}/PKG-INFO +2 -2
  2. {seqseg-2.1.4 → seqseg-2.1.6}/README.md +1 -1
  3. {seqseg-2.1.4 → seqseg-2.1.6}/pyproject.toml +1 -1
  4. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/cli.py +14 -6
  5. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/sitk_functions.py +62 -4
  6. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/pipeline/classic.py +30 -9
  7. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/pipeline/plus.py +27 -6
  8. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/pipeline/train.py +215 -48
  9. seqseg-2.1.6/seqseg/scripts/prepare_casx_dataset.py +437 -0
  10. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/test_cli_helpers.py +31 -0
  11. seqseg-2.1.6/seqseg/tests/test_prepare_casx_dataset.py +45 -0
  12. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/test_resample_spacing.py +36 -2
  13. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/test_train_pipeline.py +147 -0
  14. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg.egg-info/PKG-INFO +2 -2
  15. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg.egg-info/SOURCES.txt +2 -0
  16. {seqseg-2.1.4 → seqseg-2.1.6}/LICENSE +0 -0
  17. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/__init__.py +0 -0
  18. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/api.py +0 -0
  19. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/citations.py +0 -0
  20. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/config/__init__.py +0 -0
  21. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/config/aorta_tutorial.yaml +0 -0
  22. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/config/global.yaml +0 -0
  23. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/config/global_aorta.yaml +0 -0
  24. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/config/global_cereb.yaml +0 -0
  25. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/config/global_coro.yaml +0 -0
  26. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/config/global_debug.yaml +0 -0
  27. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/config/global_default.yaml +0 -0
  28. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/config/global_pulm.yaml +0 -0
  29. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/config/global_seg.yaml +0 -0
  30. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/config/global_test.yaml +0 -0
  31. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/config_models.py +0 -0
  32. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/__init__.py +0 -0
  33. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/assembly.py +0 -0
  34. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/capping.py +0 -0
  35. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/centerline.py +0 -0
  36. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/datasets.py +0 -0
  37. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/evaluation.py +0 -0
  38. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/initialization.py +0 -0
  39. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/local_assembly.py +0 -0
  40. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/nnunet.py +0 -0
  41. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/params.py +0 -0
  42. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/prediction.py +0 -0
  43. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/simvascular.py +0 -0
  44. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/simvascular_contour.py +0 -0
  45. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/sweep.py +0 -0
  46. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/tracing.py +0 -0
  47. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/tracing_functions.py +0 -0
  48. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/vmtk_functions.py +0 -0
  49. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/modules/vtk_functions.py +0 -0
  50. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/parallel/old_auto_centerline.py +0 -0
  51. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/parallel/parallel_auto_centerline.py +0 -0
  52. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/parallel/parallel_auto_centerline2.py +0 -0
  53. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/parallel/parallel_auto_centerline3.py +0 -0
  54. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/parallel/parallel_auto_centerline4.py +0 -0
  55. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/pipeline/__init__.py +0 -0
  56. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/pipeline/config.py +0 -0
  57. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/pipeline/directories.py +0 -0
  58. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/pipeline/post.py +0 -0
  59. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/pipeline/single_trace.py +0 -0
  60. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/scripts/__init__.py +0 -0
  61. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/scripts/taubin_smooth.py +0 -0
  62. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/seqseg.py +0 -0
  63. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/seqseg_plus.py +0 -0
  64. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/conftest.py +0 -0
  65. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/test_api_entry.py +0 -0
  66. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/test_centerline_get_end_points.py +0 -0
  67. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/test_centerline_gradient.py +0 -0
  68. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/test_datasets_paths.py +0 -0
  69. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/test_nnunet2.py +0 -0
  70. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/test_nnunet_device.py +0 -0
  71. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/test_segmentation.py +0 -0
  72. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/test_single_trace_staging.py +0 -0
  73. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/test_start_seg.py +0 -0
  74. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/test_user_paths.py +0 -0
  75. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/tests/test_vtk.py +0 -0
  76. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg/user_paths.py +0 -0
  77. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg.egg-info/dependency_links.txt +0 -0
  78. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg.egg-info/entry_points.txt +0 -0
  79. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg.egg-info/requires.txt +0 -0
  80. {seqseg-2.1.4 → seqseg-2.1.6}/seqseg.egg-info/top_level.txt +0 -0
  81. {seqseg-2.1.4 → seqseg-2.1.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seqseg
3
- Version: 2.1.4
3
+ Version: 2.1.6
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
@@ -84,7 +84,7 @@ SeqSeg **2.0** refactors the package around a clearer CLI and a stable Python AP
84
84
  | **`seqseg run plus batch`** | Global nnU-Net sweep, then SeqSeg (replaces monolithic `seqseg_plus` script flow) |
85
85
  | **`seqseg init dataset`** | Scaffold `images/`, `centerlines/`, `truths/`, and template `seeds.json` |
86
86
  | **`seqseg paths init` / `set` / `show`** | Save default nnU-Net / data / out dirs in `~/.seqseg/paths.yaml` |
87
- | **`seqseg train prepare`** | Extract patches + build nnU-Net Dataset (needs `pip install "seqseg[train]"`) |
87
+ | **`seqseg train prepare`** | Extract patches or whole volumes + build nnU-Net Dataset (needs `pip install "seqseg[train]"`) |
88
88
  | **`seqseg train nnunet`** | Run nnU-Net plan/preprocess and training |
89
89
  | **`seqseg doctor`** | Check imports (SimpleITK, vtk, nnunetv2, scipy, optional sampler) and paths |
90
90
  | **`seqseg config dump` / `fingerprint`** | Inspect or diff packaged YAML configs |
@@ -51,7 +51,7 @@ SeqSeg **2.0** refactors the package around a clearer CLI and a stable Python AP
51
51
  | **`seqseg run plus batch`** | Global nnU-Net sweep, then SeqSeg (replaces monolithic `seqseg_plus` script flow) |
52
52
  | **`seqseg init dataset`** | Scaffold `images/`, `centerlines/`, `truths/`, and template `seeds.json` |
53
53
  | **`seqseg paths init` / `set` / `show`** | Save default nnU-Net / data / out dirs in `~/.seqseg/paths.yaml` |
54
- | **`seqseg train prepare`** | Extract patches + build nnU-Net Dataset (needs `pip install "seqseg[train]"`) |
54
+ | **`seqseg train prepare`** | Extract patches or whole volumes + build nnU-Net Dataset (needs `pip install "seqseg[train]"`) |
55
55
  | **`seqseg train nnunet`** | Run nnU-Net plan/preprocess and training |
56
56
  | **`seqseg doctor`** | Check imports (SimpleITK, vtk, nnunetv2, scipy, optional sampler) and paths |
57
57
  | **`seqseg config dump` / `fingerprint`** | Inspect or diff packaged YAML configs |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "seqseg"
7
- version = "2.1.4"
7
+ version = "2.1.6"
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"
@@ -672,6 +672,7 @@ def _cmd_train_prepare(ns: argparse.Namespace) -> None:
672
672
  yes=ns.yes,
673
673
  verbose=ns.verbose,
674
674
  img_ext=ns.img_ext,
675
+ global_volumes=ns.global_volumes,
675
676
  )
676
677
  except TrainDependencyError as e:
677
678
  print(str(e), file=sys.stderr)
@@ -692,7 +693,8 @@ def _cmd_train_prepare(ns: argparse.Namespace) -> None:
692
693
  sys.exit(1)
693
694
 
694
695
  print("\nPrepare complete.")
695
- print(f" Patches: {result.extracted_dir}")
696
+ kind = "Whole volumes" if ns.global_volumes else "Patches"
697
+ print(f" {kind}: {result.extracted_dir}")
696
698
  for name, path in zip(result.dataset_names, result.dataset_dirs):
697
699
  try:
698
700
  ds_id = dataset_id_from_name(name)
@@ -990,13 +992,13 @@ def _build_parser() -> argparse.ArgumentParser:
990
992
 
991
993
  train_p = sub.add_parser(
992
994
  "train",
993
- help="Prepare patch datasets and train nnU-Net models for SeqSeg",
995
+ help="Prepare patch or whole-volume datasets and train nnU-Net models",
994
996
  )
995
997
  train_sub = train_p.add_subparsers(dest="train_cmd", required=True)
996
998
 
997
999
  p_prep = train_sub.add_parser(
998
1000
  "prepare",
999
- help="Extract vascular patches and convert to nnU-Net DatasetXXX format",
1001
+ help="Prepare patches or whole volumes and convert to nnU-Net DatasetXXX format",
1000
1002
  formatter_class=argparse.ArgumentDefaultsHelpFormatter,
1001
1003
  )
1002
1004
  p_prep.add_argument(
@@ -1012,7 +1014,7 @@ def _build_parser() -> argparse.ArgumentParser:
1012
1014
  "-outdir",
1013
1015
  default=None,
1014
1016
  type=str,
1015
- help="Directory for extracted patches "
1017
+ help="Directory for extracted patches or whole volumes "
1016
1018
  "(default: seqseg paths outdir, else ./extracted_data/)",
1017
1019
  )
1018
1020
  p_prep.add_argument(
@@ -1078,15 +1080,21 @@ def _build_parser() -> argparse.ArgumentParser:
1078
1080
  default=None,
1079
1081
  )
1080
1082
  p_prep.add_argument("--truth-regenerate", action="store_true")
1083
+ p_prep.add_argument(
1084
+ "--global-volumes",
1085
+ "--whole-volumes",
1086
+ action="store_true",
1087
+ help="Copy each case's full image and label instead of sampling patches",
1088
+ )
1081
1089
  p_prep.add_argument(
1082
1090
  "--skip-sample",
1083
1091
  action="store_true",
1084
- help="Only convert existing patches in --outdir",
1092
+ help="Only convert existing volumes in --outdir",
1085
1093
  )
1086
1094
  p_prep.add_argument(
1087
1095
  "--skip-convert",
1088
1096
  action="store_true",
1089
- help="Only extract patches; skip nnU-Net Dataset conversion",
1097
+ help="Only write volumes; skip nnU-Net Dataset conversion",
1090
1098
  )
1091
1099
  p_prep.add_argument(
1092
1100
  "--also-test",
@@ -55,10 +55,10 @@ def copy_settings(img, ref_img):
55
55
  return img
56
56
 
57
57
 
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
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
- 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
 
@@ -35,12 +37,99 @@ def _require_sampler():
35
37
  return extract_patches, write_nnunet_dataset
36
38
 
37
39
 
40
+ def _require_gather_global_volumes():
41
+ try:
42
+ from vascular_segment_sampler.sampling import gather_global_volumes
43
+ except ImportError as e:
44
+ raise TrainDependencyError(
45
+ "Whole-volume dataset construction requires gather_global_volumes "
46
+ "from a recent vascular-segment-sampler.\n"
47
+ "Upgrade with:\n"
48
+ ' pip install -U "seqseg[train]"\n'
49
+ "or:\n"
50
+ " pip install -U vascular-segment-sampler"
51
+ ) from e
52
+ return gather_global_volumes
53
+
54
+
55
+ def _reduction_or_nan(arr: Any, fn: Callable[[Any], Any]) -> float:
56
+ """``np.amin``/``mean`` on an empty crop raise; treat that as missing."""
57
+ arr = np.asarray(arr)
58
+ if arr.size == 0:
59
+ return float("nan")
60
+ return float(fn(arr))
61
+
62
+
63
+ def _point_list(location: Any) -> list:
64
+ if hasattr(location, "tolist"):
65
+ return location.tolist()
66
+ return list(location)
67
+
68
+
69
+ def _safe_add_image_stats(stats: dict, im_np: Any) -> dict:
70
+ stats.update(
71
+ {
72
+ "IM_MEAN": _reduction_or_nan(im_np, np.mean),
73
+ "IM_MIN": _reduction_or_nan(im_np, np.amin),
74
+ "IM_STD": _reduction_or_nan(im_np, np.std),
75
+ "IM_MAX": _reduction_or_nan(im_np, np.amax),
76
+ }
77
+ )
78
+ return stats
79
+
80
+
81
+ def _safe_add_local_stats(
82
+ stats: dict,
83
+ location: Any,
84
+ diff_cent: Any,
85
+ blood_np: Any,
86
+ ground_truth: Any,
87
+ means: Sequence,
88
+ removed_seg: Any,
89
+ im_np: Any,
90
+ O: int,
91
+ ) -> tuple[dict, int]:
92
+ """Same stats as the sampler, but empty labeled crops become NaN not errors."""
93
+ stats.update(
94
+ {
95
+ "DIFF_CENT": diff_cent,
96
+ "POINT_CENT": _point_list(location),
97
+ "BLOOD_MEAN": _reduction_or_nan(blood_np, np.mean),
98
+ "BLOOD_MIN": _reduction_or_nan(blood_np, np.amin),
99
+ "BLOOD_STD": _reduction_or_nan(blood_np, np.std),
100
+ "BLOOD_MAX": _reduction_or_nan(blood_np, np.amax),
101
+ "GT_MEAN": _reduction_or_nan(ground_truth, np.mean),
102
+ "GT_STD": _reduction_or_nan(ground_truth, np.std),
103
+ "GT_MAX": _reduction_or_nan(ground_truth, np.amax),
104
+ "GT_MIN": _reduction_or_nan(ground_truth, np.amin),
105
+ }
106
+ )
107
+ if len(means) != 1:
108
+ import SimpleITK as sitk
109
+
110
+ larg_np = sitk.GetArrayFromImage(removed_seg)
111
+ rem_np = np.asarray(im_np)[np.asarray(larg_np) > 0.1]
112
+ stats.update(
113
+ {
114
+ "LARGEST_MEAN": _reduction_or_nan(rem_np, np.mean),
115
+ "LARGEST_STD": _reduction_or_nan(rem_np, np.std),
116
+ "LARGEST_MAX": _reduction_or_nan(rem_np, np.amax),
117
+ "LARGEST_MIN": _reduction_or_nan(rem_np, np.amin),
118
+ }
119
+ )
120
+ O += 1
121
+ return stats, O
122
+
123
+
38
124
  def _harden_sampler_vtk() -> None:
39
- """Skip non-numeric VTK arrays the sampler used to pass to vtk_to_numpy.
125
+ """Patch sampler helpers that break on common SeqSeg datasets.
40
126
 
41
127
  Centerline .vtp files from SimVascular/Slicer often include string/abstract
42
128
  arrays. ``GetArray(i)`` is None for those, which became
43
129
  ``'NoneType' object has no attribute 'GetDataType'``.
130
+
131
+ Crops with no labeled voxels used to raise
132
+ ``zero-size array to reduction operation minimum`` in ``add_local_stats``.
44
133
  """
45
134
  try:
46
135
  import vascular_segment_sampler.sampling.extract as extract_mod
@@ -57,6 +146,13 @@ def _harden_sampler_vtk() -> None:
57
146
  samp_fn.collect_arrays = collect_arrays
58
147
  vf.convertPolyDataToImageData = convertPolyDataToImageData
59
148
 
149
+ if not getattr(samp_fn.add_local_stats, "_seqseg_hardened", False):
150
+ _safe_add_local_stats._seqseg_hardened = True # type: ignore[attr-defined]
151
+ samp_fn.add_local_stats = _safe_add_local_stats
152
+ if not getattr(samp_fn.add_image_stats, "_seqseg_hardened", False):
153
+ _safe_add_image_stats._seqseg_hardened = True # type: ignore[attr-defined]
154
+ samp_fn.add_image_stats = _safe_add_image_stats
155
+
60
156
  orig_sort = getattr(samp_fn, "sort_centerline", None)
61
157
  if orig_sort is not None and not getattr(orig_sort, "_seqseg_hardened", False):
62
158
 
@@ -268,11 +364,22 @@ def _preflight_training_data(
268
364
  )
269
365
 
270
366
 
271
- def _missing_patches_error(outdir: str, modalities: Sequence[str]) -> RuntimeError:
272
- lines = [
273
- "Patch extraction finished without writing any samples.",
274
- f" outdir: {os.path.abspath(outdir)}",
275
- ]
367
+ def _missing_volumes_error(
368
+ outdir: str,
369
+ modalities: Sequence[str],
370
+ *,
371
+ global_volumes: bool = False,
372
+ ) -> RuntimeError:
373
+ if global_volumes:
374
+ lines = [
375
+ "Whole-volume gathering finished without writing any cases.",
376
+ f" outdir: {os.path.abspath(outdir)}",
377
+ ]
378
+ else:
379
+ lines = [
380
+ "Patch extraction finished without writing any samples.",
381
+ f" outdir: {os.path.abspath(outdir)}",
382
+ ]
276
383
  for mod in modalities:
277
384
  img_dir = os.path.join(outdir, f"{mod.lower()}_train")
278
385
  mask_dir = os.path.join(outdir, f"{mod.lower()}_train_masks")
@@ -280,29 +387,47 @@ def _missing_patches_error(outdir: str, modalities: Sequence[str]) -> RuntimeErr
280
387
  f" {mod}: {_count_volume_files(img_dir)} images in {img_dir}, "
281
388
  f"{_count_volume_files(mask_dir)} masks in {mask_dir}"
282
389
  )
283
- lines.extend(
284
- [
285
- "",
286
- "vascular-segment-sampler always opens "
287
- "{modality}_train_Sample_stats.csv at the end, even when no case "
288
- "completed (so that FileNotFoundError is a symptom, not the cause).",
289
- "Typical causes:",
290
- " - Worker crashes with --num-cores > 1 (exceptions are not re-raised).",
291
- " Re-run with --num-cores 1 to see the real error.",
292
- " - Image extension mismatch (pass --img-ext, e.g. .nii.gz).",
293
- " - Missing truths/ (or surfaces/ when using --truth-from-surface).",
294
- " - All cases listed in outdir/done.txt, so they were skipped.",
295
- ]
296
- )
390
+ if global_volumes:
391
+ lines.extend(
392
+ [
393
+ "",
394
+ "Typical causes:",
395
+ " - Image extension mismatch (pass --img-ext, e.g. .nii.gz).",
396
+ " - Missing truths/ (or surfaces/ when using --truth-from-surface).",
397
+ " - All cases listed in outdir/done.txt, so they were skipped.",
398
+ ]
399
+ )
400
+ else:
401
+ lines.extend(
402
+ [
403
+ "",
404
+ "vascular-segment-sampler always opens "
405
+ "{modality}_train_Sample_stats.csv at the end, even when no case "
406
+ "completed (so that FileNotFoundError is a symptom, not the cause).",
407
+ "Typical causes:",
408
+ " - Worker crashes with --num-cores > 1 (exceptions are not re-raised).",
409
+ " Re-run with --num-cores 1 to see the real error.",
410
+ " - Image extension mismatch (pass --img-ext, e.g. .nii.gz).",
411
+ " - Missing truths/ (or surfaces/ when using --truth-from-surface).",
412
+ " - All cases listed in outdir/done.txt, so they were skipped.",
413
+ ]
414
+ )
297
415
  return RuntimeError("\n".join(lines))
298
416
 
299
417
 
300
- def _assert_patches_extracted(outdir: str, modalities: Sequence[str]) -> None:
418
+ def _assert_volumes_written(
419
+ outdir: str,
420
+ modalities: Sequence[str],
421
+ *,
422
+ global_volumes: bool = False,
423
+ ) -> None:
301
424
  for mod in modalities:
302
425
  img_dir = os.path.join(outdir, f"{mod.lower()}_train")
303
426
  mask_dir = os.path.join(outdir, f"{mod.lower()}_train_masks")
304
427
  if _count_volume_files(img_dir) == 0 or _count_volume_files(mask_dir) == 0:
305
- raise _missing_patches_error(outdir, modalities)
428
+ raise _missing_volumes_error(
429
+ outdir, modalities, global_volumes=global_volumes
430
+ )
306
431
 
307
432
 
308
433
  @contextmanager
@@ -394,9 +519,13 @@ def prepare_training_dataset(
394
519
  yes: bool = False,
395
520
  verbose: bool = False,
396
521
  img_ext: Optional[str] = None,
522
+ global_volumes: bool = False,
397
523
  ) -> PrepareResult:
398
524
  """
399
- Extract SeqSeg-style patches and convert them to nnU-Net raw datasets.
525
+ Prepare training volumes and convert them to nnU-Net raw datasets.
526
+
527
+ By default extracts SeqSeg-style patches. With ``global_volumes=True``,
528
+ copies each case's full image and label instead (``gather_global_volumes``).
400
529
 
401
530
  Uses ``vascular-segment-sampler`` (``pip install seqseg[train]``).
402
531
  """
@@ -432,35 +561,70 @@ def prepare_training_dataset(
432
561
  truth_from_surface=truth_from_surface,
433
562
  )
434
563
  print("=" * 72)
435
- print("Extracting vascular segment patches")
564
+ if global_volumes:
565
+ print("Gathering whole-case volumes (no patch sampling)")
566
+ else:
567
+ print("Extracting vascular segment patches")
436
568
  print(f" data_dir: {data_dir}")
437
569
  print(f" outdir: {outdir}")
438
570
  print(f" IMG_EXT: {resolved_img_ext}")
439
571
  print("=" * 72)
440
- _run_extract_patches(
441
- extract_patches,
442
- data_dir=data_dir,
443
- outdir=outdir,
444
- config=sampler_config,
445
- perc_dataset=perc_dataset,
446
- num_cores=num_cores,
447
- start_from=start_from,
448
- end_at=end_at,
449
- testing=testing,
450
- validation_prop=validation_prop,
451
- max_samples=max_samples,
452
- modality=modality_arg,
453
- truth_from_surface=truth_from_surface,
454
- truth_target_spacing=(
455
- list(truth_target_spacing) if truth_target_spacing is not None else None
456
- ),
457
- truth_regenerate=truth_regenerate,
458
- yes=yes,
459
- verbose=verbose,
572
+ if global_volumes:
573
+ gather_global_volumes = _require_gather_global_volumes()
574
+ gather_global_volumes(
575
+ data_dir=data_dir,
576
+ outdir=outdir,
577
+ config=sampler_config,
578
+ perc_dataset=perc_dataset,
579
+ num_cores=num_cores,
580
+ start_from=start_from,
581
+ end_at=end_at,
582
+ testing=testing,
583
+ validation_prop=validation_prop,
584
+ modality=modality_arg,
585
+ truth_from_surface=truth_from_surface,
586
+ truth_target_spacing=(
587
+ list(truth_target_spacing)
588
+ if truth_target_spacing is not None
589
+ else None
590
+ ),
591
+ truth_regenerate=truth_regenerate,
592
+ yes=yes,
593
+ verbose=verbose,
594
+ )
595
+ else:
596
+ _run_extract_patches(
597
+ extract_patches,
598
+ data_dir=data_dir,
599
+ outdir=outdir,
600
+ config=sampler_config,
601
+ perc_dataset=perc_dataset,
602
+ num_cores=num_cores,
603
+ start_from=start_from,
604
+ end_at=end_at,
605
+ testing=testing,
606
+ validation_prop=validation_prop,
607
+ max_samples=max_samples,
608
+ modality=modality_arg,
609
+ truth_from_surface=truth_from_surface,
610
+ truth_target_spacing=(
611
+ list(truth_target_spacing)
612
+ if truth_target_spacing is not None
613
+ else None
614
+ ),
615
+ truth_regenerate=truth_regenerate,
616
+ yes=yes,
617
+ verbose=verbose,
618
+ )
619
+ _assert_volumes_written(
620
+ outdir, modalities, global_volumes=global_volumes
460
621
  )
461
- _assert_patches_extracted(outdir, modalities)
462
622
  else:
463
- print("Skipping patch extraction (--skip-sample)")
623
+ print(
624
+ "Skipping volume gathering (--skip-sample)"
625
+ if global_volumes
626
+ else "Skipping patch extraction (--skip-sample)"
627
+ )
464
628
 
465
629
  dataset_dirs: List[str] = []
466
630
  dataset_names: List[str] = []
@@ -474,7 +638,10 @@ def prepare_training_dataset(
474
638
  os.makedirs(convert_outdir, exist_ok=True)
475
639
 
476
640
  print("=" * 72)
477
- print(f"Converting patches to nnU-Net format under {convert_outdir}")
641
+ print(
642
+ f"Converting {'volumes' if global_volumes else 'patches'} "
643
+ f"to nnU-Net format under {convert_outdir}"
644
+ )
478
645
  print("=" * 72)
479
646
 
480
647
  # Dataset numbers must be unique per modality when converting several.