seqseg 2.1.2__tar.gz → 2.1.3__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 (78) hide show
  1. {seqseg-2.1.2 → seqseg-2.1.3}/PKG-INFO +1 -1
  2. {seqseg-2.1.2 → seqseg-2.1.3}/pyproject.toml +1 -1
  3. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/cli.py +10 -1
  4. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/pipeline/train.py +265 -3
  5. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/tests/test_cli_helpers.py +2 -0
  6. seqseg-2.1.3/seqseg/tests/test_train_pipeline.py +288 -0
  7. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg.egg-info/PKG-INFO +1 -1
  8. seqseg-2.1.2/seqseg/tests/test_train_pipeline.py +0 -130
  9. {seqseg-2.1.2 → seqseg-2.1.3}/LICENSE +0 -0
  10. {seqseg-2.1.2 → seqseg-2.1.3}/README.md +0 -0
  11. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/__init__.py +0 -0
  12. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/api.py +0 -0
  13. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/citations.py +0 -0
  14. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/config/__init__.py +0 -0
  15. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/config/aorta_tutorial.yaml +0 -0
  16. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/config/global.yaml +0 -0
  17. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/config/global_aorta.yaml +0 -0
  18. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/config/global_cereb.yaml +0 -0
  19. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/config/global_coro.yaml +0 -0
  20. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/config/global_debug.yaml +0 -0
  21. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/config/global_default.yaml +0 -0
  22. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/config/global_pulm.yaml +0 -0
  23. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/config/global_seg.yaml +0 -0
  24. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/config/global_test.yaml +0 -0
  25. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/config_models.py +0 -0
  26. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/__init__.py +0 -0
  27. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/assembly.py +0 -0
  28. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/capping.py +0 -0
  29. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/centerline.py +0 -0
  30. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/datasets.py +0 -0
  31. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/evaluation.py +0 -0
  32. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/initialization.py +0 -0
  33. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/local_assembly.py +0 -0
  34. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/nnunet.py +0 -0
  35. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/params.py +0 -0
  36. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/prediction.py +0 -0
  37. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/simvascular.py +0 -0
  38. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/simvascular_contour.py +0 -0
  39. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/sitk_functions.py +0 -0
  40. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/sweep.py +0 -0
  41. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/tracing.py +0 -0
  42. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/tracing_functions.py +0 -0
  43. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/vmtk_functions.py +0 -0
  44. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/modules/vtk_functions.py +0 -0
  45. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/parallel/old_auto_centerline.py +0 -0
  46. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/parallel/parallel_auto_centerline.py +0 -0
  47. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/parallel/parallel_auto_centerline2.py +0 -0
  48. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/parallel/parallel_auto_centerline3.py +0 -0
  49. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/parallel/parallel_auto_centerline4.py +0 -0
  50. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/pipeline/__init__.py +0 -0
  51. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/pipeline/classic.py +0 -0
  52. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/pipeline/config.py +0 -0
  53. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/pipeline/directories.py +0 -0
  54. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/pipeline/plus.py +0 -0
  55. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/pipeline/post.py +0 -0
  56. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/pipeline/single_trace.py +0 -0
  57. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/scripts/__init__.py +0 -0
  58. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/scripts/taubin_smooth.py +0 -0
  59. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/seqseg.py +0 -0
  60. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/seqseg_plus.py +0 -0
  61. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/tests/conftest.py +0 -0
  62. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/tests/test_api_entry.py +0 -0
  63. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/tests/test_centerline_get_end_points.py +0 -0
  64. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/tests/test_datasets_paths.py +0 -0
  65. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/tests/test_nnunet2.py +0 -0
  66. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/tests/test_nnunet_device.py +0 -0
  67. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/tests/test_segmentation.py +0 -0
  68. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/tests/test_single_trace_staging.py +0 -0
  69. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/tests/test_start_seg.py +0 -0
  70. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/tests/test_user_paths.py +0 -0
  71. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/tests/test_vtk.py +0 -0
  72. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg/user_paths.py +0 -0
  73. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg.egg-info/SOURCES.txt +0 -0
  74. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg.egg-info/dependency_links.txt +0 -0
  75. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg.egg-info/entry_points.txt +0 -0
  76. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg.egg-info/requires.txt +0 -0
  77. {seqseg-2.1.2 → seqseg-2.1.3}/seqseg.egg-info/top_level.txt +0 -0
  78. {seqseg-2.1.2 → seqseg-2.1.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seqseg
3
- Version: 2.1.2
3
+ Version: 2.1.3
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.2"
7
+ version = "2.1.3"
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"
@@ -671,6 +671,7 @@ def _cmd_train_prepare(ns: argparse.Namespace) -> None:
671
671
  also_test=ns.also_test,
672
672
  yes=ns.yes,
673
673
  verbose=ns.verbose,
674
+ img_ext=ns.img_ext,
674
675
  )
675
676
  except TrainDependencyError as e:
676
677
  print(str(e), file=sys.stderr)
@@ -1038,6 +1039,14 @@ def _build_parser() -> argparse.ArgumentParser:
1038
1039
  help="Write DatasetXXX_* here "
1039
1040
  "(default: seqseg paths nnunet_raw / $nnUNet_raw; else --outdir)",
1040
1041
  )
1042
+ p_prep.add_argument(
1043
+ "--img-ext",
1044
+ "-img_ext",
1045
+ default=None,
1046
+ type=str,
1047
+ help="Image/label suffix (.nii.gz, .mha, .nrrd, …). "
1048
+ "Default: detect from data_dir/images/; else sampler YAML IMG_EXT",
1049
+ )
1041
1050
  p_prep.add_argument("--perc-dataset", default=1.0, type=float)
1042
1051
  p_prep.add_argument("--num-cores", default=1, type=int)
1043
1052
  p_prep.add_argument("--start-from", default=0, type=int)
@@ -1075,7 +1084,7 @@ def _build_parser() -> argparse.ArgumentParser:
1075
1084
  )
1076
1085
  p_prep.add_argument("--yes", action="store_true", help="Non-interactive confirms")
1077
1086
  p_prep.add_argument("--verbose", action="store_true")
1078
- p_prep.set_defaults(_handler=_cmd_train_prepare)
1087
+ p_prep.set_defaults(_handler=_cmd_train_prepare, img_ext=None)
1079
1088
 
1080
1089
  p_nn = train_sub.add_parser(
1081
1090
  "nnunet",
@@ -5,12 +5,15 @@ from __future__ import annotations
5
5
  import os
6
6
  import shutil
7
7
  import subprocess
8
+ from contextlib import contextmanager
8
9
  from dataclasses import dataclass
9
10
  from pathlib import Path
10
- from typing import List, Optional, Sequence, Union
11
+ from typing import Iterator, List, Optional, Sequence, Union
11
12
 
12
13
  from seqseg.user_paths import apply_nnunet_env, ensure_nnunet_dirs, resolve_path
13
14
 
15
+ _VOLUME_EXTS = (".nii.gz", ".nrrd", ".nii", ".mha", ".mhd")
16
+
14
17
 
15
18
  class TrainDependencyError(RuntimeError):
16
19
  """Raised when optional training dependencies are missing."""
@@ -48,6 +51,250 @@ def _dir_for_sampler(path: str) -> str:
48
51
  return os.path.join(os.path.abspath(os.path.expanduser(path)), "")
49
52
 
50
53
 
54
+ def _is_missing_stats_csv(exc: BaseException) -> bool:
55
+ return isinstance(exc, FileNotFoundError) and "Sample_stats.csv" in str(exc)
56
+
57
+
58
+ def _normalize_img_ext(ext: str) -> str:
59
+ ext = str(ext).strip()
60
+ if not ext.startswith("."):
61
+ ext = "." + ext
62
+ return ext
63
+
64
+
65
+ def _volume_ext_of(name: str) -> Optional[str]:
66
+ lower = name.lower()
67
+ for ext in _VOLUME_EXTS:
68
+ if lower.endswith(ext):
69
+ return ext
70
+ return None
71
+
72
+
73
+ def _detect_img_ext(images_dir: Path) -> Optional[str]:
74
+ """Pick the most common volume suffix under ``images/`` (``.nii.gz`` first)."""
75
+ if not images_dir.is_dir():
76
+ return None
77
+ counts: dict[str, int] = {}
78
+ for name in os.listdir(images_dir):
79
+ if name.startswith("."):
80
+ continue
81
+ ext = _volume_ext_of(name)
82
+ if ext:
83
+ counts[ext] = counts.get(ext, 0) + 1
84
+ if not counts:
85
+ return None
86
+ best = max(counts.values())
87
+ for ext in _VOLUME_EXTS:
88
+ if counts.get(ext) == best:
89
+ return ext
90
+ return None
91
+
92
+
93
+ def _load_sampler_config(config: Union[str, dict]) -> dict:
94
+ if isinstance(config, dict):
95
+ return dict(config)
96
+ try:
97
+ from vascular_segment_sampler.sampling.extract import _load_config
98
+
99
+ return dict(_load_config(config))
100
+ except Exception: # noqa: BLE001 — tests and missing sampler YAML
101
+ return {"IMG_EXT": ".nrrd"}
102
+
103
+
104
+ def _resolve_img_ext(
105
+ data_dir: str,
106
+ config: dict,
107
+ img_ext: Optional[str] = None,
108
+ ) -> str:
109
+ if img_ext:
110
+ return _normalize_img_ext(img_ext)
111
+ detected = _detect_img_ext(Path(data_dir) / "images")
112
+ if detected:
113
+ return detected
114
+ return _normalize_img_ext(str(config.get("IMG_EXT") or ".nrrd"))
115
+
116
+
117
+ def _stems_with_suffix(directory: Path, suffix: str) -> List[str]:
118
+ if not directory.is_dir():
119
+ return []
120
+ stems: List[str] = []
121
+ for name in os.listdir(directory):
122
+ if name.startswith("."):
123
+ continue
124
+ if name.endswith(suffix):
125
+ stems.append(name[: -len(suffix)])
126
+ return sorted(stems)
127
+
128
+
129
+ def _count_volume_files(directory: str) -> int:
130
+ if not os.path.isdir(directory):
131
+ return 0
132
+ n = 0
133
+ for name in os.listdir(directory):
134
+ if name.startswith("."):
135
+ continue
136
+ if name.endswith(".nii.gz") or name.endswith(_VOLUME_EXTS[1:]):
137
+ n += 1
138
+ return n
139
+
140
+
141
+ def _preflight_training_data(
142
+ data_dir: str,
143
+ *,
144
+ img_ext: str,
145
+ truth_from_surface: bool,
146
+ ) -> None:
147
+ """Fail fast when the sampler would iterate zero usable cases."""
148
+ root = Path(data_dir)
149
+ images = root / "images"
150
+ centerlines = root / "centerlines"
151
+ missing = [
152
+ str(p)
153
+ for p, label in ((images, "images"), (centerlines, "centerlines"))
154
+ if not p.is_dir()
155
+ ]
156
+ if missing:
157
+ raise FileNotFoundError(
158
+ "Training data_dir must contain images/ and centerlines/.\n"
159
+ f" data_dir: {root}\n"
160
+ " missing: " + ", ".join(missing)
161
+ )
162
+
163
+ img_stems = _stems_with_suffix(images, img_ext)
164
+ if not img_stems:
165
+ present = sorted(
166
+ {
167
+ name
168
+ for name in os.listdir(images)
169
+ if not name.startswith(".")
170
+ }
171
+ )
172
+ preview = ", ".join(present[:8]) if present else "(empty)"
173
+ raise FileNotFoundError(
174
+ f"No images with IMG_EXT={img_ext!r} in {images}.\n"
175
+ f" files found: {preview}\n"
176
+ "Pass --img-ext (e.g. .nii.gz or .mha) or convert images."
177
+ )
178
+
179
+ cent_stems = _stems_with_suffix(centerlines, ".vtp")
180
+ if not cent_stems:
181
+ raise FileNotFoundError(
182
+ f"No .vtp centerlines in {centerlines}."
183
+ )
184
+
185
+ overlap = sorted(set(img_stems) & set(cent_stems))
186
+ if not overlap:
187
+ raise FileNotFoundError(
188
+ "No case has both an image and a matching centerline stem.\n"
189
+ f" image stems ({img_ext}): {img_stems[:8]}\n"
190
+ f" centerline stems (.vtp): {cent_stems[:8]}"
191
+ )
192
+
193
+ if truth_from_surface:
194
+ surfaces = root / "surfaces"
195
+ if not surfaces.is_dir():
196
+ raise FileNotFoundError(
197
+ "--truth-from-surface requires a surfaces/ folder under "
198
+ f"{root}."
199
+ )
200
+ surf_stems = _stems_with_suffix(surfaces, ".vtp") + _stems_with_suffix(
201
+ surfaces, ".stl"
202
+ )
203
+ if not surf_stems:
204
+ raise FileNotFoundError(
205
+ f"No .vtp/.stl surfaces in {surfaces}."
206
+ )
207
+ surf_overlap = sorted(set(overlap) & set(surf_stems))
208
+ if not surf_overlap:
209
+ raise FileNotFoundError(
210
+ "No case has an image, centerline, and surface with the same stem.\n"
211
+ f" usable image+centerline stems: {overlap[:8]}\n"
212
+ f" surface stems: {sorted(set(surf_stems))[:8]}"
213
+ )
214
+
215
+
216
+ def _missing_patches_error(outdir: str, modalities: Sequence[str]) -> RuntimeError:
217
+ lines = [
218
+ "Patch extraction finished without writing any samples.",
219
+ f" outdir: {os.path.abspath(outdir)}",
220
+ ]
221
+ for mod in modalities:
222
+ img_dir = os.path.join(outdir, f"{mod.lower()}_train")
223
+ mask_dir = os.path.join(outdir, f"{mod.lower()}_train_masks")
224
+ lines.append(
225
+ f" {mod}: {_count_volume_files(img_dir)} images in {img_dir}, "
226
+ f"{_count_volume_files(mask_dir)} masks in {mask_dir}"
227
+ )
228
+ lines.extend(
229
+ [
230
+ "",
231
+ "vascular-segment-sampler always opens "
232
+ "{modality}_train_Sample_stats.csv at the end, even when no case "
233
+ "completed (so that FileNotFoundError is a symptom, not the cause).",
234
+ "Typical causes:",
235
+ " - Worker crashes with --num-cores > 1 (exceptions are not re-raised).",
236
+ " Re-run with --num-cores 1 to see the real error.",
237
+ " - Image extension mismatch (pass --img-ext, e.g. .nii.gz).",
238
+ " - Missing truths/ (or surfaces/ when using --truth-from-surface).",
239
+ " - All cases listed in outdir/done.txt, so they were skipped.",
240
+ ]
241
+ )
242
+ return RuntimeError("\n".join(lines))
243
+
244
+
245
+ def _assert_patches_extracted(outdir: str, modalities: Sequence[str]) -> None:
246
+ for mod in modalities:
247
+ img_dir = os.path.join(outdir, f"{mod.lower()}_train")
248
+ mask_dir = os.path.join(outdir, f"{mod.lower()}_train_masks")
249
+ if _count_volume_files(img_dir) == 0 or _count_volume_files(mask_dir) == 0:
250
+ raise _missing_patches_error(outdir, modalities)
251
+
252
+
253
+ @contextmanager
254
+ def _tolerate_missing_sampler_stats() -> Iterator[None]:
255
+ """Skip the sampler's end-of-run CSV summary when no case wrote it."""
256
+ try:
257
+ import vascular_segment_sampler.sampling.extract as extract_mod
258
+ except ImportError:
259
+ yield
260
+ return
261
+
262
+ orig = getattr(extract_mod, "print_csv_stats", None)
263
+ if orig is None:
264
+ yield
265
+ return
266
+
267
+ def _safe(out_dir, global_config, modality):
268
+ suffix = global_config.get("OUTPUT_SUFFIX", "") or ""
269
+ if global_config.get("TESTING"):
270
+ csv_file = f"{modality}_test{suffix}_Sample_stats.csv"
271
+ else:
272
+ csv_file = f"{modality}_train{suffix}_Sample_stats.csv"
273
+ path = out_dir + csv_file
274
+ if not os.path.isfile(path):
275
+ print(
276
+ f"Warning: no sampler stats file at {path}; continuing."
277
+ )
278
+ return
279
+ orig(out_dir, global_config, modality)
280
+
281
+ extract_mod.print_csv_stats = _safe
282
+ try:
283
+ yield
284
+ finally:
285
+ extract_mod.print_csv_stats = orig
286
+
287
+
288
+ def _run_extract_patches(extract_patches, **kwargs) -> None:
289
+ try:
290
+ with _tolerate_missing_sampler_stats():
291
+ extract_patches(**kwargs)
292
+ except FileNotFoundError as e:
293
+ if not _is_missing_stats_csv(e):
294
+ raise
295
+ print(f"Warning: {e}")
296
+
297
+
51
298
  def expected_nnunet_dataset_name(name: str, dataset_number: int, modality: str) -> str:
52
299
  """Match vascular-segment-sampler naming for DatasetXXX_* folders."""
53
300
  if dataset_number < 10:
@@ -91,6 +338,7 @@ def prepare_training_dataset(
91
338
  also_test: bool = False,
92
339
  yes: bool = False,
93
340
  verbose: bool = False,
341
+ img_ext: Optional[str] = None,
94
342
  ) -> PrepareResult:
95
343
  """
96
344
  Extract SeqSeg-style patches and convert them to nnU-Net raw datasets.
@@ -118,14 +366,27 @@ def prepare_training_dataset(
118
366
  modalities = _parse_modalities(modality)
119
367
  modality_arg = ",".join(modalities)
120
368
 
369
+ sampler_config = _load_sampler_config(config)
370
+ resolved_img_ext = _resolve_img_ext(data_dir, sampler_config, img_ext)
371
+ sampler_config["IMG_EXT"] = resolved_img_ext
372
+
121
373
  if not skip_sample:
374
+ _preflight_training_data(
375
+ data_dir,
376
+ img_ext=resolved_img_ext,
377
+ truth_from_surface=truth_from_surface,
378
+ )
122
379
  print("=" * 72)
123
380
  print("Extracting vascular segment patches")
381
+ print(f" data_dir: {data_dir}")
382
+ print(f" outdir: {outdir}")
383
+ print(f" IMG_EXT: {resolved_img_ext}")
124
384
  print("=" * 72)
125
- extract_patches(
385
+ _run_extract_patches(
386
+ extract_patches,
126
387
  data_dir=data_dir,
127
388
  outdir=outdir,
128
- config=config,
389
+ config=sampler_config,
129
390
  perc_dataset=perc_dataset,
130
391
  num_cores=num_cores,
131
392
  start_from=start_from,
@@ -142,6 +403,7 @@ def prepare_training_dataset(
142
403
  yes=yes,
143
404
  verbose=verbose,
144
405
  )
406
+ _assert_patches_extracted(outdir, modalities)
145
407
  else:
146
408
  print("Skipping patch extraction (--skip-sample)")
147
409
 
@@ -288,6 +288,7 @@ def test_parser_train_prepare():
288
288
  assert ns.data_dir == "/tmp/data"
289
289
  assert ns.name == "MYDATA"
290
290
  assert ns.dataset_number == 999
291
+ assert ns.img_ext is None
291
292
 
292
293
 
293
294
  def test_parser_paths_init():
@@ -349,6 +350,7 @@ def test_cmd_train_prepare_missing_dep(tmp_path, capsys):
349
350
  also_test=False,
350
351
  yes=False,
351
352
  verbose=False,
353
+ img_ext=None,
352
354
  )
353
355
  with patch(
354
356
  "seqseg.cli.prepare_training_dataset",
@@ -0,0 +1,288 @@
1
+ """Unit tests for seqseg.pipeline.train helpers (no GPU / sampler required)."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ import os
8
+
9
+ from pathlib import Path
10
+
11
+ from seqseg.pipeline.train import (
12
+ TrainDependencyError,
13
+ _detect_img_ext,
14
+ _dir_for_sampler,
15
+ _normalize_img_ext,
16
+ _resolve_img_ext,
17
+ dataset_id_from_name,
18
+ expected_nnunet_dataset_name,
19
+ prepare_training_dataset,
20
+ run_nnunet_training,
21
+ )
22
+
23
+
24
+ def _make_training_data(tmp_path, *, surfaces=False, img_ext=".nrrd"):
25
+ data = tmp_path / "data"
26
+ (data / "images").mkdir(parents=True)
27
+ (data / "centerlines").mkdir()
28
+ (data / "images" / f"case1{img_ext}").write_bytes(b"x")
29
+ (data / "centerlines" / "case1.vtp").write_bytes(b"x")
30
+ if surfaces:
31
+ (data / "surfaces").mkdir()
32
+ (data / "surfaces" / "case1.vtp").write_bytes(b"x")
33
+ return str(data)
34
+
35
+
36
+ def _make_patch_dirs(extracted, modality="ct"):
37
+ img_dir = extracted / f"{modality}_train"
38
+ mask_dir = extracted / f"{modality}_train_masks"
39
+ img_dir.mkdir(parents=True)
40
+ mask_dir.mkdir()
41
+ (img_dir / "a.nrrd").write_bytes(b"x")
42
+ (mask_dir / "a.nrrd").write_bytes(b"x")
43
+ return img_dir, mask_dir
44
+
45
+
46
+ def test_dir_for_sampler_adds_trailing_sep(tmp_path):
47
+ raw = str(tmp_path / "seqseg_train")
48
+ out = _dir_for_sampler(raw)
49
+ assert out.endswith(os.sep)
50
+ assert out.rstrip("/\\") == os.path.abspath(raw)
51
+ # Sampler concatenates outdir + "ct_train_Sample_stats.csv"
52
+ stats = out + "ct_train_Sample_stats.csv"
53
+ assert os.path.dirname(stats) == os.path.abspath(raw)
54
+
55
+
56
+ def test_expected_nnunet_dataset_name_padding():
57
+ assert expected_nnunet_dataset_name("AORTAS", 1, "ct") == "Dataset001_AORTASCT"
58
+ assert expected_nnunet_dataset_name("AORTAS", 10, "mr") == "Dataset010_AORTASMR"
59
+ assert expected_nnunet_dataset_name("MYDATA", 999, "CT") == "Dataset0999_MYDATACT"
60
+
61
+
62
+ def test_dataset_id_from_name_errors():
63
+ with pytest.raises(ValueError):
64
+ dataset_id_from_name("not_a_dataset")
65
+ with pytest.raises(ValueError):
66
+ dataset_id_from_name("Dataset_FOO")
67
+
68
+
69
+ def test_prepare_requires_sampler():
70
+ with patch(
71
+ "seqseg.pipeline.train._require_sampler",
72
+ side_effect=TrainDependencyError("nope"),
73
+ ):
74
+ with pytest.raises(TrainDependencyError):
75
+ prepare_training_dataset(
76
+ "/tmp/data",
77
+ "/tmp/out",
78
+ name="X",
79
+ dataset_number=1,
80
+ )
81
+
82
+
83
+ def test_prepare_calls_sampler_apis(tmp_path):
84
+ extract = MagicMock()
85
+ write = MagicMock(return_value=str(tmp_path / "Dataset0999_MYDATACT"))
86
+ data = _make_training_data(tmp_path)
87
+ extracted = tmp_path / "extracted"
88
+ _make_patch_dirs(extracted)
89
+
90
+ with patch(
91
+ "seqseg.pipeline.train._require_sampler",
92
+ return_value=(extract, write),
93
+ ):
94
+ result = prepare_training_dataset(
95
+ data,
96
+ str(extracted),
97
+ name="MYDATA",
98
+ dataset_number=999,
99
+ modality="CT",
100
+ yes=True,
101
+ )
102
+
103
+ extract.assert_called_once()
104
+ write.assert_called_once()
105
+ outdir_arg = extract.call_args.kwargs["outdir"]
106
+ assert outdir_arg.endswith(os.sep)
107
+ assert os.path.basename(os.path.normpath(outdir_arg)) == "extracted"
108
+ assert extract.call_args.kwargs["config"]["IMG_EXT"] == ".nrrd"
109
+ assert result.dataset_names == ["Dataset0999_MYDATACT"]
110
+ assert result.modalities == ["CT"]
111
+
112
+
113
+ def test_detect_img_ext_prefers_nii_gz(tmp_path):
114
+ images = tmp_path / "images"
115
+ images.mkdir()
116
+ (images / "716.nii.gz").write_bytes(b"x")
117
+ (images / "notes.txt").write_text("nope")
118
+ assert _detect_img_ext(images) == ".nii.gz"
119
+ assert _normalize_img_ext("nii.gz") == ".nii.gz"
120
+ assert _resolve_img_ext(str(tmp_path), {"IMG_EXT": ".nrrd"}) == ".nii.gz"
121
+
122
+
123
+ def test_prepare_detects_nii_gz_and_sets_sampler_config(tmp_path):
124
+ extract = MagicMock()
125
+ write = MagicMock(return_value=str(tmp_path / "Dataset0999_MYDATACT"))
126
+ data = _make_training_data(tmp_path, img_ext=".nii.gz")
127
+ extracted = tmp_path / "extracted"
128
+ _make_patch_dirs(extracted)
129
+
130
+ with patch(
131
+ "seqseg.pipeline.train._require_sampler",
132
+ return_value=(extract, write),
133
+ ):
134
+ prepare_training_dataset(
135
+ data,
136
+ str(extracted),
137
+ name="MYDATA",
138
+ dataset_number=999,
139
+ yes=True,
140
+ )
141
+
142
+ assert extract.call_args.kwargs["config"]["IMG_EXT"] == ".nii.gz"
143
+
144
+
145
+ def test_prepare_explicit_img_ext_overrides_detection(tmp_path):
146
+ extract = MagicMock()
147
+ write = MagicMock(return_value=str(tmp_path / "Dataset0999_MYDATACT"))
148
+ data = Path(_make_training_data(tmp_path, img_ext=".nii.gz"))
149
+ (data / "images" / "case1.mha").write_bytes(b"x")
150
+ extracted = tmp_path / "extracted"
151
+ _make_patch_dirs(extracted)
152
+
153
+ with patch(
154
+ "seqseg.pipeline.train._require_sampler",
155
+ return_value=(extract, write),
156
+ ):
157
+ prepare_training_dataset(
158
+ str(data),
159
+ str(extracted),
160
+ name="MYDATA",
161
+ dataset_number=999,
162
+ img_ext="mha",
163
+ yes=True,
164
+ )
165
+
166
+ assert extract.call_args.kwargs["config"]["IMG_EXT"] == ".mha"
167
+
168
+
169
+ def test_prepare_multi_modality_increments_ids(tmp_path):
170
+ extract = MagicMock()
171
+ write = MagicMock(
172
+ side_effect=[
173
+ str(tmp_path / "Dataset0999_MYDATACT"),
174
+ str(tmp_path / "Dataset1000_MYDATAMR"),
175
+ ]
176
+ )
177
+
178
+ with patch(
179
+ "seqseg.pipeline.train._require_sampler",
180
+ return_value=(extract, write),
181
+ ):
182
+ result = prepare_training_dataset(
183
+ str(tmp_path / "data"),
184
+ str(tmp_path / "extracted"),
185
+ name="MYDATA",
186
+ dataset_number=999,
187
+ modality="CT,MR",
188
+ skip_sample=True,
189
+ )
190
+
191
+ assert write.call_count == 2
192
+ assert result.dataset_names == [
193
+ "Dataset0999_MYDATACT",
194
+ "Dataset01000_MYDATAMR",
195
+ ]
196
+
197
+
198
+ def test_prepare_preflight_rejects_missing_images(tmp_path):
199
+ extract = MagicMock()
200
+ write = MagicMock()
201
+ empty = tmp_path / "data"
202
+ empty.mkdir()
203
+ with patch(
204
+ "seqseg.pipeline.train._require_sampler",
205
+ return_value=(extract, write),
206
+ ):
207
+ with pytest.raises(FileNotFoundError, match="images/ and centerlines"):
208
+ prepare_training_dataset(
209
+ str(empty),
210
+ str(tmp_path / "extracted"),
211
+ name="MYDATA",
212
+ dataset_number=999,
213
+ yes=True,
214
+ )
215
+ extract.assert_not_called()
216
+
217
+
218
+ def test_prepare_continues_if_stats_csv_missing_but_patches_exist(tmp_path):
219
+ extracted = tmp_path / "extracted"
220
+ _make_patch_dirs(extracted)
221
+ extract = MagicMock(
222
+ side_effect=FileNotFoundError(
223
+ f"[Errno 2] No such file or directory: "
224
+ f"'{extracted}/ct_train_Sample_stats.csv'"
225
+ )
226
+ )
227
+ write = MagicMock(return_value=str(tmp_path / "Dataset0999_MYDATACT"))
228
+
229
+ with patch(
230
+ "seqseg.pipeline.train._require_sampler",
231
+ return_value=(extract, write),
232
+ ):
233
+ result = prepare_training_dataset(
234
+ _make_training_data(tmp_path),
235
+ str(extracted),
236
+ name="MYDATA",
237
+ dataset_number=999,
238
+ yes=True,
239
+ )
240
+
241
+ write.assert_called_once()
242
+ assert result.dataset_names == ["Dataset0999_MYDATACT"]
243
+
244
+
245
+ def test_prepare_missing_stats_csv_without_patches_is_actionable(tmp_path):
246
+ extract = MagicMock(
247
+ side_effect=FileNotFoundError(
248
+ "[Errno 2] No such file or directory: "
249
+ "'/scratch/11178/numi/seqseg_train/ct_train_Sample_stats.csv'"
250
+ )
251
+ )
252
+ write = MagicMock()
253
+
254
+ with patch(
255
+ "seqseg.pipeline.train._require_sampler",
256
+ return_value=(extract, write),
257
+ ):
258
+ with pytest.raises(RuntimeError, match="--num-cores 1"):
259
+ prepare_training_dataset(
260
+ _make_training_data(tmp_path),
261
+ str(tmp_path / "extracted"),
262
+ name="MYDATA",
263
+ dataset_number=999,
264
+ yes=True,
265
+ )
266
+ write.assert_not_called()
267
+
268
+
269
+ def test_run_nnunet_training_requires_env(tmp_path, monkeypatch):
270
+ monkeypatch.setenv("SEQSEG_PATHS_FILE", str(tmp_path / "no_paths.yaml"))
271
+ for key in ("nnUNet_raw", "nnUNet_preprocessed", "nnUNet_results"):
272
+ monkeypatch.delenv(key, raising=False)
273
+ with pytest.raises(RuntimeError, match="nnU-Net paths"):
274
+ run_nnunet_training(999, plan_only=True)
275
+
276
+
277
+ def test_run_nnunet_training_plan_only(monkeypatch):
278
+ monkeypatch.setenv("nnUNet_raw", "/raw")
279
+ monkeypatch.setenv("nnUNet_preprocessed", "/pre")
280
+ monkeypatch.setenv("nnUNet_results", "/res")
281
+
282
+ with patch("seqseg.pipeline.train.subprocess.run") as run:
283
+ run_nnunet_training(999, plan_only=True, configuration="3d_fullres")
284
+ assert run.call_count == 1
285
+ cmd = run.call_args[0][0]
286
+ assert "nnUNetv2_plan_and_preprocess" in cmd[0]
287
+ assert cmd[cmd.index("-d") + 1] == "999"
288
+ assert cmd[cmd.index("-c") + 1] == "3d_fullres"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seqseg
3
- Version: 2.1.2
3
+ Version: 2.1.3
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
@@ -1,130 +0,0 @@
1
- """Unit tests for seqseg.pipeline.train helpers (no GPU / sampler required)."""
2
-
3
- from unittest.mock import MagicMock, patch
4
-
5
- import pytest
6
-
7
- import os
8
-
9
- from seqseg.pipeline.train import (
10
- TrainDependencyError,
11
- _dir_for_sampler,
12
- dataset_id_from_name,
13
- expected_nnunet_dataset_name,
14
- prepare_training_dataset,
15
- run_nnunet_training,
16
- )
17
-
18
-
19
- def test_dir_for_sampler_adds_trailing_sep(tmp_path):
20
- raw = str(tmp_path / "seqseg_train")
21
- out = _dir_for_sampler(raw)
22
- assert out.endswith(os.sep)
23
- assert out.rstrip("/\\") == os.path.abspath(raw)
24
- # Sampler concatenates outdir + "ct_train_Sample_stats.csv"
25
- stats = out + "ct_train_Sample_stats.csv"
26
- assert os.path.dirname(stats) == os.path.abspath(raw)
27
-
28
-
29
- def test_expected_nnunet_dataset_name_padding():
30
- assert expected_nnunet_dataset_name("AORTAS", 1, "ct") == "Dataset001_AORTASCT"
31
- assert expected_nnunet_dataset_name("AORTAS", 10, "mr") == "Dataset010_AORTASMR"
32
- assert expected_nnunet_dataset_name("MYDATA", 999, "CT") == "Dataset0999_MYDATACT"
33
-
34
-
35
- def test_dataset_id_from_name_errors():
36
- with pytest.raises(ValueError):
37
- dataset_id_from_name("not_a_dataset")
38
- with pytest.raises(ValueError):
39
- dataset_id_from_name("Dataset_FOO")
40
-
41
-
42
- def test_prepare_requires_sampler():
43
- with patch(
44
- "seqseg.pipeline.train._require_sampler",
45
- side_effect=TrainDependencyError("nope"),
46
- ):
47
- with pytest.raises(TrainDependencyError):
48
- prepare_training_dataset(
49
- "/tmp/data",
50
- "/tmp/out",
51
- name="X",
52
- dataset_number=1,
53
- )
54
-
55
-
56
- def test_prepare_calls_sampler_apis(tmp_path):
57
- extract = MagicMock()
58
- write = MagicMock(return_value=str(tmp_path / "Dataset0999_MYDATACT"))
59
-
60
- with patch(
61
- "seqseg.pipeline.train._require_sampler",
62
- return_value=(extract, write),
63
- ):
64
- result = prepare_training_dataset(
65
- str(tmp_path / "data"),
66
- str(tmp_path / "extracted"),
67
- name="MYDATA",
68
- dataset_number=999,
69
- modality="CT",
70
- yes=True,
71
- )
72
-
73
- extract.assert_called_once()
74
- write.assert_called_once()
75
- outdir_arg = extract.call_args.kwargs["outdir"]
76
- assert outdir_arg.endswith(os.sep)
77
- assert os.path.basename(os.path.normpath(outdir_arg)) == "extracted"
78
- assert result.dataset_names == ["Dataset0999_MYDATACT"]
79
- assert result.modalities == ["CT"]
80
-
81
-
82
- def test_prepare_multi_modality_increments_ids(tmp_path):
83
- extract = MagicMock()
84
- write = MagicMock(
85
- side_effect=[
86
- str(tmp_path / "Dataset0999_MYDATACT"),
87
- str(tmp_path / "Dataset1000_MYDATAMR"),
88
- ]
89
- )
90
-
91
- with patch(
92
- "seqseg.pipeline.train._require_sampler",
93
- return_value=(extract, write),
94
- ):
95
- result = prepare_training_dataset(
96
- str(tmp_path / "data"),
97
- str(tmp_path / "extracted"),
98
- name="MYDATA",
99
- dataset_number=999,
100
- modality="CT,MR",
101
- skip_sample=True,
102
- )
103
-
104
- assert write.call_count == 2
105
- assert result.dataset_names == [
106
- "Dataset0999_MYDATACT",
107
- "Dataset01000_MYDATAMR",
108
- ]
109
-
110
-
111
- def test_run_nnunet_training_requires_env(tmp_path, monkeypatch):
112
- monkeypatch.setenv("SEQSEG_PATHS_FILE", str(tmp_path / "no_paths.yaml"))
113
- for key in ("nnUNet_raw", "nnUNet_preprocessed", "nnUNet_results"):
114
- monkeypatch.delenv(key, raising=False)
115
- with pytest.raises(RuntimeError, match="nnU-Net paths"):
116
- run_nnunet_training(999, plan_only=True)
117
-
118
-
119
- def test_run_nnunet_training_plan_only(monkeypatch):
120
- monkeypatch.setenv("nnUNet_raw", "/raw")
121
- monkeypatch.setenv("nnUNet_preprocessed", "/pre")
122
- monkeypatch.setenv("nnUNet_results", "/res")
123
-
124
- with patch("seqseg.pipeline.train.subprocess.run") as run:
125
- run_nnunet_training(999, plan_only=True, configuration="3d_fullres")
126
- assert run.call_count == 1
127
- cmd = run.call_args[0][0]
128
- assert "nnUNetv2_plan_and_preprocess" in cmd[0]
129
- assert cmd[cmd.index("-d") + 1] == "999"
130
- assert cmd[cmd.index("-c") + 1] == "3d_fullres"
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