seqseg 2.1.4__tar.gz → 2.1.5__tar.gz

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