dcccpy 0.3.0a2__tar.gz → 0.3.0a4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dcccpy
3
- Version: 0.3.0a2
3
+ Version: 0.3.0a4
4
4
  Summary: Python wrapper for the DCCCcore PET biomarker command-line tool
5
5
  Author: DCCCSlicer contributors
6
6
  License-Expression: CC-BY-NC-ND-4.0
@@ -17,20 +17,50 @@ Requires-Python: >=3.9
17
17
  Description-Content-Type: text/markdown
18
18
  Requires-Dist: nibabel>=5
19
19
  Provides-Extra: linux-runtime
20
- Requires-Dist: dcccpy-linux-runtime==0.2.0a1; (sys_platform == "linux" and platform_machine == "x86_64") and extra == "linux-runtime"
21
- Requires-Dist: dcccpy-linux-arm64-runtime==0.2.0a1; (sys_platform == "linux" and platform_machine == "aarch64") and extra == "linux-runtime"
20
+ Requires-Dist: dcccpy-linux-runtime==0.2.0a3; (sys_platform == "linux" and platform_machine == "x86_64") and extra == "linux-runtime"
21
+ Requires-Dist: dcccpy-linux-arm64-runtime==0.2.0a3; (sys_platform == "linux" and platform_machine == "aarch64") and extra == "linux-runtime"
22
22
  Provides-Extra: windows-runtime
23
- Requires-Dist: dcccpy-windows-runtime==0.2.0a1; (sys_platform == "win32" and platform_machine == "AMD64") and extra == "windows-runtime"
23
+ Requires-Dist: dcccpy-windows-runtime==0.2.0a3; (sys_platform == "win32" and platform_machine == "AMD64") and extra == "windows-runtime"
24
24
  Provides-Extra: macos-runtime
25
- Requires-Dist: dcccpy-macos-runtime==0.2.0a1; (sys_platform == "darwin" and platform_machine == "arm64") and extra == "macos-runtime"
25
+ Requires-Dist: dcccpy-macos-runtime==0.2.0a3; (sys_platform == "darwin" and platform_machine == "arm64") and extra == "macos-runtime"
26
26
  Provides-Extra: runtime
27
- Requires-Dist: dcccpy-linux-runtime==0.2.0a1; (sys_platform == "linux" and platform_machine == "x86_64") and extra == "runtime"
28
- Requires-Dist: dcccpy-linux-arm64-runtime==0.2.0a1; (sys_platform == "linux" and platform_machine == "aarch64") and extra == "runtime"
29
- Requires-Dist: dcccpy-windows-runtime==0.2.0a1; (sys_platform == "win32" and platform_machine == "AMD64") and extra == "runtime"
30
- Requires-Dist: dcccpy-macos-runtime==0.2.0a1; (sys_platform == "darwin" and platform_machine == "arm64") and extra == "runtime"
27
+ Requires-Dist: dcccpy-linux-runtime==0.2.0a3; (sys_platform == "linux" and platform_machine == "x86_64") and extra == "runtime"
28
+ Requires-Dist: dcccpy-linux-arm64-runtime==0.2.0a3; (sys_platform == "linux" and platform_machine == "aarch64") and extra == "runtime"
29
+ Requires-Dist: dcccpy-windows-runtime==0.2.0a3; (sys_platform == "win32" and platform_machine == "AMD64") and extra == "runtime"
30
+ Requires-Dist: dcccpy-macos-runtime==0.2.0a3; (sys_platform == "darwin" and platform_machine == "arm64") and extra == "runtime"
31
31
 
32
32
  # dcccpy
33
33
 
34
+ ### Native PET segmentation
35
+
36
+ ```python
37
+ import dcccpy
38
+ result = dcccpy.seg("pet.nii.gz", "labels.nii.gz")
39
+ labels = result.load_output()
40
+ ```
41
+
42
+ Requires a DCCCcore build containing the `seg` command and its bundled model.
43
+ The signed integer output matches the input PET's native shape and geometry.
44
+ The default `mode="freesurfer"` returns raw DKT/aseg IDs; `mode="training"`
45
+ returns IDs 0–95. `mode="neurosegmenter"` provides partial regional compatibility:
46
+ brainstem/pons IDs 45/46 are unavailable, so pons SUVR is unsupported.
47
+ Mirror TTA is enabled by default; `tta=False` explicitly changes that protocol.
48
+ Use `threads=4` to select the CPU thread count, `bundle=...` to override the model
49
+ directory, and `debug=True` to save intermediate grids and a transform receipt.
50
+ The approximately 120 MB segmentation model is excluded from PyPI wheels.
51
+ With a slim runtime, the first `seg()` call downloads the separately published
52
+ `DCCCseg-4.6.0-alpha.zip` Release asset, verifies the model SHA-256 and caches it
53
+ under the dcccpy cache directory. A full native installation reuses its bundled
54
+ model. Existing `DCCCPY_AUTO_DOWNLOAD=0` disables implicit downloads; provision
55
+ offline use with `dcccpy.download_seg_model()` or pass `bundle=` pointing to
56
+ the extracted `seg` directory. `DCCCPY_SEG_MODEL_URL` selects a mirror while
57
+ retaining checksum verification.
58
+ An explicit `config=` delegates model selection to that configuration and
59
+ suppresses default-model provisioning. With a slim runtime and a configuration
60
+ that only changes other settings, also pass `bundle=dcccpy.download_seg_model()`.
61
+ An optional `rigid_header=...` must retain the exact native PET values and record
62
+ exactly two rigid steps. Existing output images are refused by the core.
63
+
34
64
  `dcccpy` is a Python wrapper for the `DCCCcore` command-line tool from
35
65
  DCCCSlicer. It keeps the native C++ core as the execution engine and provides a
36
66
  small Python API for running common PET biomarker workflows.
@@ -147,6 +177,7 @@ dcccpy.adni_pet_core(
147
177
  "standardized.nii.gz",
148
178
  tracer="fdg",
149
179
  level=(1, 3),
180
+ deface=True,
150
181
  )
151
182
  ```
152
183
 
@@ -155,9 +186,14 @@ Level 3 is the standardized ADNI-style image. The output argument names the
155
186
  highest selected level, while selected lower levels receive `_Coreg` or
156
187
  `_Coreg_Avg` suffixes.
157
188
 
158
- `adni_pet_core` deliberately has no default tracer. Use `abeta`, `tau`, or
159
- `fdg`; FDG selects ADNI's iterative masked global-mean normalization, while
160
- Aβ and tau retain cerebellar gray normalization.
189
+ Set `deface=True` to zero facial voxels in every selected output. Motion and
190
+ spatial transforms are estimated from the original PET before the privacy mask
191
+ is applied, so the flag does not change the estimated alignment.
192
+
193
+ `adni_pet_core` deliberately has no default tracer. Use `abeta`, `tau`, `fdg`,
194
+ or `dat`; FDG selects ADNI's iterative masked global-mean normalization,
195
+ DAT selects PPMI-style occipital-reference normalization, and Aβ/tau retain
196
+ cerebellar gray normalization.
161
197
 
162
198
  Downloaded ADNI preprocessed FDG DICOMs can differ slightly from the exact
163
199
  published iteration. See the repository's
@@ -206,7 +242,7 @@ the calculation again.
206
242
  Release wheels should vendor the matching `DCCCcore` runtime tree before build:
207
243
 
208
244
  ```bash
209
- python scripts/vendor_dccccore.py --version 4.5.0-alpha --release-platform ubuntu-latest-x64
245
+ python scripts/vendor_dccccore.py --version 4.6.0-alpha --release-platform ubuntu-latest-x64
210
246
  python -m build --wheel
211
247
  ```
212
248
 
@@ -214,7 +250,7 @@ The Linux ARM64 runtime package uses the corresponding release asset:
214
250
 
215
251
  ```bash
216
252
  cd python/dcccpy-linux-arm64-runtime
217
- python scripts/vendor_dccccore.py --version 4.5.0-alpha --release-platform ubuntu-latest-arm64
253
+ python scripts/vendor_dccccore.py --version 4.6.0-alpha --release-platform ubuntu-latest-arm64
218
254
  python -m build --wheel
219
255
  ```
220
256
 
@@ -232,7 +268,7 @@ The preferred distribution layout is:
232
268
 
233
269
  ## Packaging note
234
270
 
235
- The current optional runtime wheels use a PyPI-size profile for DCCCcore version 4.5.0-alpha. They omit the
271
+ The current optional runtime wheels use a PyPI-size profile for DCCCcore version 4.6.0-alpha. They omit the
236
272
  `fast_and_acc` registration model/config and the ADAD decoupler ONNX ensemble,
237
273
  while keeping the default spatial normalization model and assets needed by
238
274
  common Centiloid/CenTauR/CenTauRz workflows.
@@ -1,36 +1,35 @@
1
- Metadata-Version: 2.4
2
- Name: dcccpy
3
- Version: 0.3.0a2
4
- Summary: Python wrapper for the DCCCcore PET biomarker command-line tool
5
- Author: DCCCSlicer contributors
6
- License-Expression: CC-BY-NC-ND-4.0
7
- Project-URL: Homepage, https://github.com/tctco/DCCCSlicer
8
- Project-URL: Repository, https://github.com/tctco/DCCCSlicer
9
- Project-URL: Issues, https://github.com/tctco/DCCCSlicer/issues
10
- Keywords: PET,neuroimaging,centiloid,DCCC,DCCCSlicer
11
- Classifier: Development Status :: 3 - Alpha
12
- Classifier: Intended Audience :: Science/Research
13
- Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3 :: Only
15
- Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
16
- Requires-Python: >=3.9
17
- Description-Content-Type: text/markdown
18
- Requires-Dist: nibabel>=5
19
- Provides-Extra: linux-runtime
20
- Requires-Dist: dcccpy-linux-runtime==0.2.0a1; (sys_platform == "linux" and platform_machine == "x86_64") and extra == "linux-runtime"
21
- Requires-Dist: dcccpy-linux-arm64-runtime==0.2.0a1; (sys_platform == "linux" and platform_machine == "aarch64") and extra == "linux-runtime"
22
- Provides-Extra: windows-runtime
23
- Requires-Dist: dcccpy-windows-runtime==0.2.0a1; (sys_platform == "win32" and platform_machine == "AMD64") and extra == "windows-runtime"
24
- Provides-Extra: macos-runtime
25
- Requires-Dist: dcccpy-macos-runtime==0.2.0a1; (sys_platform == "darwin" and platform_machine == "arm64") and extra == "macos-runtime"
26
- Provides-Extra: runtime
27
- Requires-Dist: dcccpy-linux-runtime==0.2.0a1; (sys_platform == "linux" and platform_machine == "x86_64") and extra == "runtime"
28
- Requires-Dist: dcccpy-linux-arm64-runtime==0.2.0a1; (sys_platform == "linux" and platform_machine == "aarch64") and extra == "runtime"
29
- Requires-Dist: dcccpy-windows-runtime==0.2.0a1; (sys_platform == "win32" and platform_machine == "AMD64") and extra == "runtime"
30
- Requires-Dist: dcccpy-macos-runtime==0.2.0a1; (sys_platform == "darwin" and platform_machine == "arm64") and extra == "runtime"
31
-
32
1
  # dcccpy
33
2
 
3
+ ### Native PET segmentation
4
+
5
+ ```python
6
+ import dcccpy
7
+ result = dcccpy.seg("pet.nii.gz", "labels.nii.gz")
8
+ labels = result.load_output()
9
+ ```
10
+
11
+ Requires a DCCCcore build containing the `seg` command and its bundled model.
12
+ The signed integer output matches the input PET's native shape and geometry.
13
+ The default `mode="freesurfer"` returns raw DKT/aseg IDs; `mode="training"`
14
+ returns IDs 0–95. `mode="neurosegmenter"` provides partial regional compatibility:
15
+ brainstem/pons IDs 45/46 are unavailable, so pons SUVR is unsupported.
16
+ Mirror TTA is enabled by default; `tta=False` explicitly changes that protocol.
17
+ Use `threads=4` to select the CPU thread count, `bundle=...` to override the model
18
+ directory, and `debug=True` to save intermediate grids and a transform receipt.
19
+ The approximately 120 MB segmentation model is excluded from PyPI wheels.
20
+ With a slim runtime, the first `seg()` call downloads the separately published
21
+ `DCCCseg-4.6.0-alpha.zip` Release asset, verifies the model SHA-256 and caches it
22
+ under the dcccpy cache directory. A full native installation reuses its bundled
23
+ model. Existing `DCCCPY_AUTO_DOWNLOAD=0` disables implicit downloads; provision
24
+ offline use with `dcccpy.download_seg_model()` or pass `bundle=` pointing to
25
+ the extracted `seg` directory. `DCCCPY_SEG_MODEL_URL` selects a mirror while
26
+ retaining checksum verification.
27
+ An explicit `config=` delegates model selection to that configuration and
28
+ suppresses default-model provisioning. With a slim runtime and a configuration
29
+ that only changes other settings, also pass `bundle=dcccpy.download_seg_model()`.
30
+ An optional `rigid_header=...` must retain the exact native PET values and record
31
+ exactly two rigid steps. Existing output images are refused by the core.
32
+
34
33
  `dcccpy` is a Python wrapper for the `DCCCcore` command-line tool from
35
34
  DCCCSlicer. It keeps the native C++ core as the execution engine and provides a
36
35
  small Python API for running common PET biomarker workflows.
@@ -147,6 +146,7 @@ dcccpy.adni_pet_core(
147
146
  "standardized.nii.gz",
148
147
  tracer="fdg",
149
148
  level=(1, 3),
149
+ deface=True,
150
150
  )
151
151
  ```
152
152
 
@@ -155,9 +155,14 @@ Level 3 is the standardized ADNI-style image. The output argument names the
155
155
  highest selected level, while selected lower levels receive `_Coreg` or
156
156
  `_Coreg_Avg` suffixes.
157
157
 
158
- `adni_pet_core` deliberately has no default tracer. Use `abeta`, `tau`, or
159
- `fdg`; FDG selects ADNI's iterative masked global-mean normalization, while
160
- Aβ and tau retain cerebellar gray normalization.
158
+ Set `deface=True` to zero facial voxels in every selected output. Motion and
159
+ spatial transforms are estimated from the original PET before the privacy mask
160
+ is applied, so the flag does not change the estimated alignment.
161
+
162
+ `adni_pet_core` deliberately has no default tracer. Use `abeta`, `tau`, `fdg`,
163
+ or `dat`; FDG selects ADNI's iterative masked global-mean normalization,
164
+ DAT selects PPMI-style occipital-reference normalization, and Aβ/tau retain
165
+ cerebellar gray normalization.
161
166
 
162
167
  Downloaded ADNI preprocessed FDG DICOMs can differ slightly from the exact
163
168
  published iteration. See the repository's
@@ -206,7 +211,7 @@ the calculation again.
206
211
  Release wheels should vendor the matching `DCCCcore` runtime tree before build:
207
212
 
208
213
  ```bash
209
- python scripts/vendor_dccccore.py --version 4.5.0-alpha --release-platform ubuntu-latest-x64
214
+ python scripts/vendor_dccccore.py --version 4.6.0-alpha --release-platform ubuntu-latest-x64
210
215
  python -m build --wheel
211
216
  ```
212
217
 
@@ -214,7 +219,7 @@ The Linux ARM64 runtime package uses the corresponding release asset:
214
219
 
215
220
  ```bash
216
221
  cd python/dcccpy-linux-arm64-runtime
217
- python scripts/vendor_dccccore.py --version 4.5.0-alpha --release-platform ubuntu-latest-arm64
222
+ python scripts/vendor_dccccore.py --version 4.6.0-alpha --release-platform ubuntu-latest-arm64
218
223
  python -m build --wheel
219
224
  ```
220
225
 
@@ -232,7 +237,7 @@ The preferred distribution layout is:
232
237
 
233
238
  ## Packaging note
234
239
 
235
- The current optional runtime wheels use a PyPI-size profile for DCCCcore version 4.5.0-alpha. They omit the
240
+ The current optional runtime wheels use a PyPI-size profile for DCCCcore version 4.6.0-alpha. They omit the
236
241
  `fast_and_acc` registration model/config and the ADAD decoupler ONNX ensemble,
237
242
  while keeping the default spatial normalization model and assets needed by
238
243
  common Centiloid/CenTauR/CenTauRz workflows.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dcccpy"
7
- version = "0.3.0a2"
7
+ version = "0.3.0a4"
8
8
  description = "Python wrapper for the DCCCcore PET biomarker command-line tool"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -31,16 +31,16 @@ Issues = "https://github.com/tctco/DCCCSlicer/issues"
31
31
 
32
32
  [project.optional-dependencies]
33
33
  linux-runtime = [
34
- "dcccpy-linux-runtime==0.2.0a1; sys_platform == 'linux' and platform_machine == 'x86_64'",
35
- "dcccpy-linux-arm64-runtime==0.2.0a1; sys_platform == 'linux' and platform_machine == 'aarch64'",
34
+ "dcccpy-linux-runtime==0.2.0a3; sys_platform == 'linux' and platform_machine == 'x86_64'",
35
+ "dcccpy-linux-arm64-runtime==0.2.0a3; sys_platform == 'linux' and platform_machine == 'aarch64'",
36
36
  ]
37
- windows-runtime = ["dcccpy-windows-runtime==0.2.0a1; sys_platform == 'win32' and platform_machine == 'AMD64'"]
38
- macos-runtime = ["dcccpy-macos-runtime==0.2.0a1; sys_platform == 'darwin' and platform_machine == 'arm64'"]
37
+ windows-runtime = ["dcccpy-windows-runtime==0.2.0a3; sys_platform == 'win32' and platform_machine == 'AMD64'"]
38
+ macos-runtime = ["dcccpy-macos-runtime==0.2.0a3; sys_platform == 'darwin' and platform_machine == 'arm64'"]
39
39
  runtime = [
40
- "dcccpy-linux-runtime==0.2.0a1; sys_platform == 'linux' and platform_machine == 'x86_64'",
41
- "dcccpy-linux-arm64-runtime==0.2.0a1; sys_platform == 'linux' and platform_machine == 'aarch64'",
42
- "dcccpy-windows-runtime==0.2.0a1; sys_platform == 'win32' and platform_machine == 'AMD64'",
43
- "dcccpy-macos-runtime==0.2.0a1; sys_platform == 'darwin' and platform_machine == 'arm64'",
40
+ "dcccpy-linux-runtime==0.2.0a3; sys_platform == 'linux' and platform_machine == 'x86_64'",
41
+ "dcccpy-linux-arm64-runtime==0.2.0a3; sys_platform == 'linux' and platform_machine == 'aarch64'",
42
+ "dcccpy-windows-runtime==0.2.0a3; sys_platform == 'win32' and platform_machine == 'AMD64'",
43
+ "dcccpy-macos-runtime==0.2.0a3; sys_platform == 'darwin' and platform_machine == 'arm64'",
44
44
  ]
45
45
 
46
46
  [project.scripts]
@@ -15,12 +15,16 @@ from .core import (
15
15
  parse_metrics,
16
16
  pet_motion_correct,
17
17
  rigid,
18
+ seg,
18
19
  run,
19
20
  suvr,
20
21
  )
21
22
  from .runtime import DCCCCORE_VERSION, DCCCcoreDownloadError, DCCCcoreNotFoundError, download_dccccore
23
+ from .segmentation import SegModelDownloadError, download_seg_model
22
24
 
23
25
  __all__ = [
26
+ "SegModelDownloadError",
27
+ "download_seg_model",
24
28
  "DCCCResult",
25
29
  "DCCCCORE_VERSION",
26
30
  "DCCCcoreDownloadError",
@@ -39,6 +43,7 @@ __all__ = [
39
43
  "parse_metrics",
40
44
  "pet_motion_correct",
41
45
  "rigid",
46
+ "seg",
42
47
  "run",
43
48
  "suvr",
44
49
  ]
@@ -412,6 +412,7 @@ def adni_pet_core(
412
412
  *,
413
413
  tracer: str,
414
414
  level: int | Sequence[int] = 3,
415
+ deface: bool = False,
415
416
  **kwargs: object,
416
417
  ) -> DCCCResult:
417
418
  """Run tracer-specific ADNI PET Core preprocessing.
@@ -419,6 +420,7 @@ def adni_pet_core(
419
420
  ``level`` accepts one level or a sequence: 1 exports Coreg, 2 exports
420
421
  Coreg/Avg, and 3 exports the standardized image. Multiple levels produce
421
422
  multiple files while ``output`` identifies the deepest requested level.
423
+ Set ``deface=True`` to zero facial voxels after transform estimation.
422
424
  """
423
425
 
424
426
  extra_args = list(kwargs.pop("extra_args", ()))
@@ -428,6 +430,8 @@ def adni_pet_core(
428
430
  raise ValueError("level must contain one or more of: 1, 2, 3")
429
431
  if levels != (3,):
430
432
  extra_args.extend(["--level", *(str(value) for value in levels)])
433
+ if deface:
434
+ extra_args.append("--deface")
431
435
  return _spatial_command("adni-pet-core", input, output, extra_args=extra_args, **kwargs)
432
436
 
433
437
 
@@ -439,6 +443,48 @@ def rigid(
439
443
  return _spatial_command("rigid", input, output, **kwargs)
440
444
 
441
445
 
446
+ def seg(
447
+ input: object,
448
+ output: str | os.PathLike[str] | None = None,
449
+ *,
450
+ mode: str = "freesurfer",
451
+ threads: int = 4,
452
+ tta: bool = True,
453
+ bundle: str | os.PathLike[str] | None = None,
454
+ rigid_header: str | os.PathLike[str] | None = None,
455
+ debug: bool = False,
456
+ config: str | os.PathLike[str] | None = None,
457
+ check: bool = True,
458
+ executable: str | os.PathLike[str] | None = None,
459
+ ) -> DCCCResult:
460
+ """Segment one 3-D PET into native-space categorical labels.
461
+
462
+ ``freesurfer`` emits raw DKT/aseg IDs, ``training`` IDs 0–95, and
463
+ ``neurosegmenter`` a partial aggregation without brainstem/pons IDs 45/46.
464
+ Mirror TTA is enabled by default. ``rigid_header``, when supplied, must
465
+ retain the input's voxel values after exactly two rigid predictions.
466
+ """
467
+ if mode not in ("freesurfer", "training", "neurosegmenter"):
468
+ raise ValueError("mode must be freesurfer, training, or neurosegmenter")
469
+ if isinstance(threads, bool) or not isinstance(threads, int) or threads < 1:
470
+ raise ValueError("threads must be a positive integer")
471
+ if bundle is None and config is None:
472
+ from .segmentation import default_seg_bundle
473
+ executable = dccccore_path(executable=executable)
474
+ bundle = default_seg_bundle(executable)
475
+ args: list[object] = ["--mode", mode, "--threads", threads]
476
+ if not tta:
477
+ args.append("--no-tta")
478
+ if debug:
479
+ args.append("--debug")
480
+ for flag, path in (("--bundle", bundle), ("--rigid-header", rigid_header),
481
+ ("--config", config)):
482
+ if path is not None:
483
+ args.extend([flag, _resolve_user_path(path)])
484
+ return _spatial_command("seg", input, output, extra_args=args,
485
+ check=check, executable=executable)
486
+
487
+
442
488
  def pet_motion_correct(
443
489
  input: object,
444
490
  output: str | os.PathLike[str] | None = None,
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
  from typing import Iterable
13
13
 
14
14
 
15
- DCCCCORE_VERSION = "4.5.0-alpha"
15
+ DCCCCORE_VERSION = "4.6.0-alpha"
16
16
  RELEASE_REPO = "tctco/DCCCSlicer"
17
17
 
18
18
 
@@ -0,0 +1,123 @@
1
+ """Optional DCCC Seg model delivery, kept outside PyPI wheels."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import os
6
+ from pathlib import Path, PurePosixPath
7
+ import re
8
+ import shutil
9
+ import stat
10
+ import tempfile
11
+ import urllib.request
12
+ import zipfile
13
+
14
+ from .runtime import DCCCCORE_VERSION, RELEASE_REPO, auto_download_enabled, cache_root
15
+
16
+
17
+ SEG_MODEL_SHA256 = "8ea46dc8eff44e7a6c55b93ec9a353eb676692741d56a47b04c755a3c956faf6"
18
+ MODEL_FILES = ("dccc_seg.onnx", "manifest.json", "grid.json", "plans.json", "dataset.json",
19
+ "labels.json", "neurosegmenter_mapping.json", "validation.json")
20
+
21
+
22
+ class SegModelDownloadError(RuntimeError):
23
+ """The optional segmentation model could not be provisioned."""
24
+
25
+
26
+ def seg_model_dir(version: str = DCCCCORE_VERSION) -> Path:
27
+ if not re.fullmatch(r"\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?", version):
28
+ raise ValueError("Invalid segmentation model version")
29
+ return cache_root() / "models" / "seg" / version
30
+
31
+
32
+ def _complete(directory: Path) -> bool:
33
+ return all((directory / name).is_file() for name in MODEL_FILES)
34
+
35
+
36
+ def _verified(directory: Path) -> bool:
37
+ if not _complete(directory):
38
+ return False
39
+ digest = hashlib.sha256()
40
+ with (directory / "dccc_seg.onnx").open("rb") as source:
41
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
42
+ digest.update(chunk)
43
+ return digest.hexdigest() == SEG_MODEL_SHA256
44
+
45
+
46
+ def download_seg_model(*, version: str = DCCCCORE_VERSION, force: bool = False) -> Path:
47
+ """Download and verify the optional model; return its cached bundle directory.
48
+
49
+ An explicit call provisions offline use even with DCCCPY_AUTO_DOWNLOAD=0.
50
+ DCCCPY_SEG_MODEL_URL can select a mirrored archive; the model SHA-256 is
51
+ still enforced. force=True replaces this version's model cache only.
52
+ """
53
+ destination = seg_model_dir(version)
54
+ if not force and _verified(destination):
55
+ return destination
56
+ if destination.exists() and not force:
57
+ raise SegModelDownloadError("Cached segmentation model is incomplete or corrupt; "
58
+ "call download_seg_model(force=True) to repair it")
59
+ repo = os.environ.get("DCCCPY_RELEASE_REPO", RELEASE_REPO)
60
+ url = os.environ.get("DCCCPY_SEG_MODEL_URL") or (
61
+ f"https://github.com/{repo}/releases/download/v{version}/DCCCseg-{version}.zip")
62
+ destination.parent.mkdir(parents=True, exist_ok=True)
63
+ try:
64
+ with tempfile.TemporaryDirectory(prefix=".seg-download-", dir=destination.parent) as temporary:
65
+ temporary = Path(temporary)
66
+ archive = temporary / "model.zip"
67
+ with urllib.request.urlopen(url, timeout=120) as response, archive.open("wb") as output:
68
+ # Bound compressed downloads as well as extracted size.
69
+ total = 0
70
+ while True:
71
+ chunk = response.read(1024 * 1024)
72
+ if not chunk:
73
+ break
74
+ total += len(chunk)
75
+ if total > 200 * 1024 * 1024:
76
+ raise SegModelDownloadError("Segmentation model archive exceeds size limit")
77
+ output.write(chunk)
78
+ with zipfile.ZipFile(archive) as source:
79
+ total = 0
80
+ for member in source.infolist():
81
+ path = PurePosixPath(member.filename)
82
+ if (path.is_absolute() or ".." in path.parts or "\\" in member.filename
83
+ or not path.parts or path.parts[0] != "seg"
84
+ or stat.S_ISLNK(member.external_attr >> 16)):
85
+ raise SegModelDownloadError("Unsafe segmentation model archive path")
86
+ total += member.file_size
87
+ if total > 200 * 1024 * 1024:
88
+ raise SegModelDownloadError("Segmentation model expands beyond size limit")
89
+ for name in MODEL_FILES:
90
+ output = temporary / "seg" / name
91
+ output.parent.mkdir(parents=True, exist_ok=True)
92
+ with source.open("seg/" + name) as handle, output.open("xb") as target:
93
+ shutil.copyfileobj(handle, target)
94
+ staged = temporary / "seg"
95
+ if not _verified(staged):
96
+ raise SegModelDownloadError("Segmentation model SHA-256 mismatch")
97
+ if force and destination.exists():
98
+ shutil.rmtree(destination)
99
+ try:
100
+ staged.rename(destination)
101
+ except OSError:
102
+ # Another process may have finished the same version concurrently.
103
+ if not _verified(destination):
104
+ raise
105
+ return destination
106
+ except SegModelDownloadError:
107
+ raise
108
+ except Exception as exc:
109
+ raise SegModelDownloadError("Cannot download the DCCC Seg model; provide bundle= with "
110
+ "an extracted DCCCseg Release asset for offline use") from exc
111
+
112
+
113
+ def default_seg_bundle(executable: Path) -> Path | None:
114
+ """Return None for a full native installation, otherwise provision its model."""
115
+ if _complete(Path(executable).resolve().parent / "assets/models/seg"):
116
+ return None
117
+ destination = seg_model_dir()
118
+ if _verified(destination):
119
+ return destination
120
+ if not auto_download_enabled():
121
+ raise SegModelDownloadError("Segmentation model is absent and automatic download is disabled; "
122
+ "pass bundle= or call download_seg_model() explicitly")
123
+ return download_seg_model()
@@ -1,5 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: dcccpy
3
+ Version: 0.3.0a4
4
+ Summary: Python wrapper for the DCCCcore PET biomarker command-line tool
5
+ Author: DCCCSlicer contributors
6
+ License-Expression: CC-BY-NC-ND-4.0
7
+ Project-URL: Homepage, https://github.com/tctco/DCCCSlicer
8
+ Project-URL: Repository, https://github.com/tctco/DCCCSlicer
9
+ Project-URL: Issues, https://github.com/tctco/DCCCSlicer/issues
10
+ Keywords: PET,neuroimaging,centiloid,DCCC,DCCCSlicer
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: nibabel>=5
19
+ Provides-Extra: linux-runtime
20
+ Requires-Dist: dcccpy-linux-runtime==0.2.0a3; (sys_platform == "linux" and platform_machine == "x86_64") and extra == "linux-runtime"
21
+ Requires-Dist: dcccpy-linux-arm64-runtime==0.2.0a3; (sys_platform == "linux" and platform_machine == "aarch64") and extra == "linux-runtime"
22
+ Provides-Extra: windows-runtime
23
+ Requires-Dist: dcccpy-windows-runtime==0.2.0a3; (sys_platform == "win32" and platform_machine == "AMD64") and extra == "windows-runtime"
24
+ Provides-Extra: macos-runtime
25
+ Requires-Dist: dcccpy-macos-runtime==0.2.0a3; (sys_platform == "darwin" and platform_machine == "arm64") and extra == "macos-runtime"
26
+ Provides-Extra: runtime
27
+ Requires-Dist: dcccpy-linux-runtime==0.2.0a3; (sys_platform == "linux" and platform_machine == "x86_64") and extra == "runtime"
28
+ Requires-Dist: dcccpy-linux-arm64-runtime==0.2.0a3; (sys_platform == "linux" and platform_machine == "aarch64") and extra == "runtime"
29
+ Requires-Dist: dcccpy-windows-runtime==0.2.0a3; (sys_platform == "win32" and platform_machine == "AMD64") and extra == "runtime"
30
+ Requires-Dist: dcccpy-macos-runtime==0.2.0a3; (sys_platform == "darwin" and platform_machine == "arm64") and extra == "runtime"
31
+
1
32
  # dcccpy
2
33
 
34
+ ### Native PET segmentation
35
+
36
+ ```python
37
+ import dcccpy
38
+ result = dcccpy.seg("pet.nii.gz", "labels.nii.gz")
39
+ labels = result.load_output()
40
+ ```
41
+
42
+ Requires a DCCCcore build containing the `seg` command and its bundled model.
43
+ The signed integer output matches the input PET's native shape and geometry.
44
+ The default `mode="freesurfer"` returns raw DKT/aseg IDs; `mode="training"`
45
+ returns IDs 0–95. `mode="neurosegmenter"` provides partial regional compatibility:
46
+ brainstem/pons IDs 45/46 are unavailable, so pons SUVR is unsupported.
47
+ Mirror TTA is enabled by default; `tta=False` explicitly changes that protocol.
48
+ Use `threads=4` to select the CPU thread count, `bundle=...` to override the model
49
+ directory, and `debug=True` to save intermediate grids and a transform receipt.
50
+ The approximately 120 MB segmentation model is excluded from PyPI wheels.
51
+ With a slim runtime, the first `seg()` call downloads the separately published
52
+ `DCCCseg-4.6.0-alpha.zip` Release asset, verifies the model SHA-256 and caches it
53
+ under the dcccpy cache directory. A full native installation reuses its bundled
54
+ model. Existing `DCCCPY_AUTO_DOWNLOAD=0` disables implicit downloads; provision
55
+ offline use with `dcccpy.download_seg_model()` or pass `bundle=` pointing to
56
+ the extracted `seg` directory. `DCCCPY_SEG_MODEL_URL` selects a mirror while
57
+ retaining checksum verification.
58
+ An explicit `config=` delegates model selection to that configuration and
59
+ suppresses default-model provisioning. With a slim runtime and a configuration
60
+ that only changes other settings, also pass `bundle=dcccpy.download_seg_model()`.
61
+ An optional `rigid_header=...` must retain the exact native PET values and record
62
+ exactly two rigid steps. Existing output images are refused by the core.
63
+
3
64
  `dcccpy` is a Python wrapper for the `DCCCcore` command-line tool from
4
65
  DCCCSlicer. It keeps the native C++ core as the execution engine and provides a
5
66
  small Python API for running common PET biomarker workflows.
@@ -116,6 +177,7 @@ dcccpy.adni_pet_core(
116
177
  "standardized.nii.gz",
117
178
  tracer="fdg",
118
179
  level=(1, 3),
180
+ deface=True,
119
181
  )
120
182
  ```
121
183
 
@@ -124,9 +186,14 @@ Level 3 is the standardized ADNI-style image. The output argument names the
124
186
  highest selected level, while selected lower levels receive `_Coreg` or
125
187
  `_Coreg_Avg` suffixes.
126
188
 
127
- `adni_pet_core` deliberately has no default tracer. Use `abeta`, `tau`, or
128
- `fdg`; FDG selects ADNI's iterative masked global-mean normalization, while
129
- Aβ and tau retain cerebellar gray normalization.
189
+ Set `deface=True` to zero facial voxels in every selected output. Motion and
190
+ spatial transforms are estimated from the original PET before the privacy mask
191
+ is applied, so the flag does not change the estimated alignment.
192
+
193
+ `adni_pet_core` deliberately has no default tracer. Use `abeta`, `tau`, `fdg`,
194
+ or `dat`; FDG selects ADNI's iterative masked global-mean normalization,
195
+ DAT selects PPMI-style occipital-reference normalization, and Aβ/tau retain
196
+ cerebellar gray normalization.
130
197
 
131
198
  Downloaded ADNI preprocessed FDG DICOMs can differ slightly from the exact
132
199
  published iteration. See the repository's
@@ -175,7 +242,7 @@ the calculation again.
175
242
  Release wheels should vendor the matching `DCCCcore` runtime tree before build:
176
243
 
177
244
  ```bash
178
- python scripts/vendor_dccccore.py --version 4.5.0-alpha --release-platform ubuntu-latest-x64
245
+ python scripts/vendor_dccccore.py --version 4.6.0-alpha --release-platform ubuntu-latest-x64
179
246
  python -m build --wheel
180
247
  ```
181
248
 
@@ -183,7 +250,7 @@ The Linux ARM64 runtime package uses the corresponding release asset:
183
250
 
184
251
  ```bash
185
252
  cd python/dcccpy-linux-arm64-runtime
186
- python scripts/vendor_dccccore.py --version 4.5.0-alpha --release-platform ubuntu-latest-arm64
253
+ python scripts/vendor_dccccore.py --version 4.6.0-alpha --release-platform ubuntu-latest-arm64
187
254
  python -m build --wheel
188
255
  ```
189
256
 
@@ -201,7 +268,7 @@ The preferred distribution layout is:
201
268
 
202
269
  ## Packaging note
203
270
 
204
- The current optional runtime wheels use a PyPI-size profile for DCCCcore version 4.5.0-alpha. They omit the
271
+ The current optional runtime wheels use a PyPI-size profile for DCCCcore version 4.6.0-alpha. They omit the
205
272
  `fast_and_acc` registration model/config and the ADAD decoupler ONNX ensemble,
206
273
  while keeping the default spatial normalization model and assets needed by
207
274
  common Centiloid/CenTauR/CenTauRz workflows.
@@ -7,6 +7,7 @@ src/dcccpy/cli.py
7
7
  src/dcccpy/core.py
8
8
  src/dcccpy/py.typed
9
9
  src/dcccpy/runtime.py
10
+ src/dcccpy/segmentation.py
10
11
  src/dcccpy.egg-info/PKG-INFO
11
12
  src/dcccpy.egg-info/SOURCES.txt
12
13
  src/dcccpy.egg-info/dependency_links.txt
@@ -14,4 +15,5 @@ src/dcccpy.egg-info/entry_points.txt
14
15
  src/dcccpy.egg-info/requires.txt
15
16
  src/dcccpy.egg-info/top_level.txt
16
17
  tests/test_core.py
18
+ tests/test_seg_distribution.py
17
19
  tests/test_testpypi_centiloid.py
@@ -3,31 +3,31 @@ nibabel>=5
3
3
  [linux-runtime]
4
4
 
5
5
  [linux-runtime:sys_platform == "linux" and platform_machine == "aarch64"]
6
- dcccpy-linux-arm64-runtime==0.2.0a1
6
+ dcccpy-linux-arm64-runtime==0.2.0a3
7
7
 
8
8
  [linux-runtime:sys_platform == "linux" and platform_machine == "x86_64"]
9
- dcccpy-linux-runtime==0.2.0a1
9
+ dcccpy-linux-runtime==0.2.0a3
10
10
 
11
11
  [macos-runtime]
12
12
 
13
13
  [macos-runtime:sys_platform == "darwin" and platform_machine == "arm64"]
14
- dcccpy-macos-runtime==0.2.0a1
14
+ dcccpy-macos-runtime==0.2.0a3
15
15
 
16
16
  [runtime]
17
17
 
18
18
  [runtime:sys_platform == "darwin" and platform_machine == "arm64"]
19
- dcccpy-macos-runtime==0.2.0a1
19
+ dcccpy-macos-runtime==0.2.0a3
20
20
 
21
21
  [runtime:sys_platform == "linux" and platform_machine == "aarch64"]
22
- dcccpy-linux-arm64-runtime==0.2.0a1
22
+ dcccpy-linux-arm64-runtime==0.2.0a3
23
23
 
24
24
  [runtime:sys_platform == "linux" and platform_machine == "x86_64"]
25
- dcccpy-linux-runtime==0.2.0a1
25
+ dcccpy-linux-runtime==0.2.0a3
26
26
 
27
27
  [runtime:sys_platform == "win32" and platform_machine == "AMD64"]
28
- dcccpy-windows-runtime==0.2.0a1
28
+ dcccpy-windows-runtime==0.2.0a3
29
29
 
30
30
  [windows-runtime]
31
31
 
32
32
  [windows-runtime:sys_platform == "win32" and platform_machine == "AMD64"]
33
- dcccpy-windows-runtime==0.2.0a1
33
+ dcccpy-windows-runtime==0.2.0a3
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ import subprocess
4
5
  from pathlib import Path
5
6
  import sys
6
7
  import types
@@ -20,6 +21,11 @@ from dcccpy.runtime import (
20
21
 
21
22
 
22
23
  def make_fake_dccccore(tmp_path: Path) -> Path:
24
+ bundle = tmp_path / "assets/models/seg"
25
+ bundle.mkdir(parents=True, exist_ok=True)
26
+ for name in ("dccc_seg.onnx", "manifest.json", "grid.json", "plans.json", "dataset.json",
27
+ "labels.json", "neurosegmenter_mapping.json", "validation.json"):
28
+ (bundle / name).write_bytes(b"test runtime resource")
23
29
  exe = tmp_path / "DCCCcore"
24
30
  exe.write_text(
25
31
  "#!/usr/bin/env sh\n"
@@ -46,6 +52,66 @@ def test_parse_metrics() -> None:
46
52
  assert metrics == {"fbp": 12.5, "SUVr": 1.23, "ADAD score": -0.2}
47
53
 
48
54
 
55
+ def test_seg_defaults_and_explicit_options(tmp_path, monkeypatch):
56
+ fake_args = tmp_path / "args.txt"
57
+ fake_exe = make_fake_dccccore(tmp_path)
58
+ monkeypatch.setenv("DCCCPY_FAKE_ARGS", str(fake_args))
59
+ result = dcccpy.seg("pet.nii", executable=fake_exe)
60
+ args = fake_args.read_text().splitlines()
61
+ assert args[0] == "seg"
62
+ assert args[args.index("--mode") + 1] == "freesurfer"
63
+ assert args[args.index("--threads") + 1] == "4"
64
+ assert "--no-tta" not in args
65
+ assert result.output.exists()
66
+ result = dcccpy.seg("pet.nii", tmp_path / "labels.nii.gz", mode="training",
67
+ threads=2, tta=False, bundle="model bundle",
68
+ rigid_header="rigid header.nii.gz", config="config.toml",
69
+ debug=True, executable=fake_exe)
70
+ args = fake_args.read_text().splitlines()
71
+ assert args[args.index("--mode") + 1] == "training"
72
+ assert args[args.index("--threads") + 1] == "2"
73
+ assert args[args.index("--bundle") + 1] == str(Path("model bundle").resolve())
74
+ assert args[args.index("--rigid-header") + 1] == str(Path("rigid header.nii.gz").resolve())
75
+ assert args[args.index("--config") + 1] == str(Path("config.toml").resolve())
76
+ assert "--debug" in args and "--no-tta" in args
77
+
78
+
79
+ @pytest.mark.parametrize("kwargs", [{"mode": "bogus"}, {"threads": 0},
80
+ {"threads": 1.5}, {"threads": True}])
81
+ def test_seg_rejects_bad_options(kwargs):
82
+ with pytest.raises(ValueError):
83
+ dcccpy.seg("pet.nii", **kwargs)
84
+
85
+
86
+ def test_seg_preserves_native_failure(tmp_path):
87
+ exe = make_fake_dccccore(tmp_path)
88
+ exe.write_text("#!/bin/sh\nprintf 'invalid segmentation model' >&2\nexit 7\n")
89
+ exe.chmod(0o755)
90
+ result = dcccpy.seg("pet.nii", check=False, executable=exe)
91
+ assert result.returncode == 7
92
+ assert "invalid segmentation model" in result.stderr
93
+ assert not result.output.exists()
94
+ with pytest.raises(subprocess.CalledProcessError) as error:
95
+ dcccpy.seg("pet.nii", executable=exe)
96
+ assert error.value.returncode == 7
97
+
98
+
99
+ def test_seg_custom_config_does_not_provision_or_override_model(tmp_path, monkeypatch):
100
+ exe = make_fake_dccccore(tmp_path)
101
+ (tmp_path / "assets/models/seg/dccc_seg.onnx").unlink()
102
+ args_file = tmp_path / "args.txt"
103
+ monkeypatch.setenv("DCCCPY_FAKE_ARGS", str(args_file))
104
+ monkeypatch.setenv("DCCCPY_AUTO_DOWNLOAD", "0")
105
+ monkeypatch.setenv("DCCCPY_CACHE_DIR", str(tmp_path / "empty-cache"))
106
+ config = tmp_path / "custom.toml"
107
+ config.write_text('[models]\nseg = "offline/custom/dccc_seg.onnx"\n')
108
+ result = dcccpy.seg("pet.nii", config=config, executable=exe)
109
+ assert result.returncode == 0
110
+ args = args_file.read_text().splitlines()
111
+ assert args[args.index("--config") + 1] == str(config)
112
+ assert "--bundle" not in args
113
+
114
+
49
115
  def test_centiloid_omits_output_by_creating_temp_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
50
116
  fake_args = tmp_path / "args.txt"
51
117
  fake_exe = make_fake_dccccore(tmp_path)
@@ -142,7 +208,7 @@ def test_dccccore_path_can_disable_auto_download(tmp_path: Path, monkeypatch: py
142
208
 
143
209
 
144
210
  def test_dccccore_path_auto_downloads_to_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
145
- asset_root = tmp_path / "asset" / "DCCCcore-4.5.0-alpha-ubuntu-latest-x64"
211
+ asset_root = tmp_path / "asset" / "DCCCcore-4.6.0-alpha-ubuntu-latest-x64"
146
212
  asset_root.mkdir(parents=True)
147
213
  exe = asset_root / "DCCCcore"
148
214
  exe.write_text("#!/usr/bin/env sh\nprintf 'fake dccccore\\n'\n")
@@ -150,7 +216,7 @@ def test_dccccore_path_auto_downloads_to_cache(tmp_path: Path, monkeypatch: pyte
150
216
 
151
217
  archive = tmp_path / "DCCCcore.zip"
152
218
  with zipfile.ZipFile(archive, "w") as zf:
153
- zf.write(exe, "DCCCcore-4.5.0-alpha-ubuntu-latest-x64/DCCCcore")
219
+ zf.write(exe, "DCCCcore-4.6.0-alpha-ubuntu-latest-x64/DCCCcore")
154
220
 
155
221
  monkeypatch.delenv("DCCCPY_DCCCCORE", raising=False)
156
222
  monkeypatch.setenv("DCCCPY_AUTO_DOWNLOAD", "1")
@@ -175,7 +241,7 @@ def test_find_existing_dccccore_checks_macos_runtime_package(
175
241
  exe.write_text("#!/usr/bin/env sh\n")
176
242
  exe.chmod(0o755)
177
243
 
178
- module = types.SimpleNamespace(DCCCCORE_VERSION="4.5.0-alpha", dccccore_root=lambda: root)
244
+ module = types.SimpleNamespace(DCCCCORE_VERSION="4.6.0-alpha", dccccore_root=lambda: root)
179
245
  monkeypatch.setitem(sys.modules, "dcccpy_macos_runtime", module)
180
246
  monkeypatch.setenv("PATH", str(tmp_path / "empty-path"))
181
247
 
@@ -186,10 +252,10 @@ def test_linux_arm64_release_platform() -> None:
186
252
  assert release_platform("linux-arm64") == "ubuntu-latest-arm64"
187
253
 
188
254
 
189
- def test_default_release_targets_dccccore_450_alpha() -> None:
190
- assert DCCCCORE_VERSION == "4.5.0-alpha"
255
+ def test_default_release_targets_dccccore_460_alpha() -> None:
256
+ assert DCCCCORE_VERSION == "4.6.0-alpha"
191
257
  assert release_url(platform_name="ubuntu-latest-x64").endswith(
192
- "/releases/download/v4.5.0-alpha/DCCCcore-4.5.0-alpha-ubuntu-latest-x64.zip"
258
+ "/releases/download/v4.6.0-alpha/DCCCcore-4.6.0-alpha-ubuntu-latest-x64.zip"
193
259
  )
194
260
 
195
261
 
@@ -220,7 +286,7 @@ def test_find_existing_dccccore_checks_linux_arm64_runtime_package(
220
286
  exe.write_text("#!/usr/bin/env sh\n")
221
287
  exe.chmod(0o755)
222
288
 
223
- module = types.SimpleNamespace(DCCCCORE_VERSION="4.5.0-alpha", dccccore_root=lambda: root)
289
+ module = types.SimpleNamespace(DCCCCORE_VERSION="4.6.0-alpha", dccccore_root=lambda: root)
224
290
  monkeypatch.setitem(sys.modules, "dcccpy_linux_arm64_runtime", module)
225
291
  monkeypatch.setenv("PATH", str(tmp_path / "empty-path"))
226
292
 
@@ -347,6 +413,38 @@ def test_adni_pet_core_rejects_invalid_export_levels(level: object) -> None:
347
413
  dcccpy.adni_pet_core("pet.nii", tracer="fdg", level=level) # type: ignore[arg-type]
348
414
 
349
415
 
416
+ def test_adni_pet_core_forwards_deface(
417
+ tmp_path: Path,
418
+ monkeypatch: pytest.MonkeyPatch,
419
+ ) -> None:
420
+ fake_args = tmp_path / "args.txt"
421
+ fake_exe = make_fake_dccccore(tmp_path)
422
+ input_path = tmp_path / "pet.nii"
423
+ output_path = tmp_path / "adni.nii"
424
+ input_path.write_text("fake PET")
425
+ monkeypatch.setenv("DCCCPY_DCCCCORE", str(fake_exe))
426
+ monkeypatch.setenv("DCCCPY_FAKE_ARGS", str(fake_args))
427
+
428
+ result = dcccpy.adni_pet_core(
429
+ input_path, output_path, tracer="fdg", level=(1, 3), deface=True
430
+ )
431
+
432
+ assert result.returncode == 0
433
+ assert fake_args.read_text().splitlines() == [
434
+ "adni-pet-core",
435
+ "--input",
436
+ str(input_path),
437
+ "--output",
438
+ str(output_path),
439
+ "--tracer",
440
+ "fdg",
441
+ "--level",
442
+ "1",
443
+ "3",
444
+ "--deface",
445
+ ]
446
+
447
+
350
448
  def test_run_adds_macos_security_hint_for_blocked_runtime(
351
449
  tmp_path: Path,
352
450
  monkeypatch: pytest.MonkeyPatch,
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import importlib.util
5
+ from pathlib import Path
6
+ import zipfile
7
+
8
+ import pytest
9
+
10
+
11
+ FILES = ("dccc_seg.onnx", "manifest.json", "grid.json", "plans.json", "dataset.json",
12
+ "labels.json", "neurosegmenter_mapping.json", "validation.json")
13
+
14
+
15
+ def import_script(path):
16
+ spec = importlib.util.spec_from_file_location("release_script", path)
17
+ module = importlib.util.module_from_spec(spec)
18
+ spec.loader.exec_module(module)
19
+ return module
20
+
21
+
22
+ @pytest.fixture
23
+ def distribution(tmp_path, monkeypatch):
24
+ import dcccpy.segmentation as model
25
+ archive = tmp_path / "model.zip"
26
+ with zipfile.ZipFile(archive, "w") as z:
27
+ for name in FILES:
28
+ z.writestr("seg/" + name, b"model" if name.endswith(".onnx") else b"{}")
29
+ monkeypatch.setenv("DCCCPY_CACHE_DIR", str(tmp_path / "cache"))
30
+ monkeypatch.setenv("DCCCPY_SEG_MODEL_URL", archive.as_uri())
31
+ monkeypatch.setattr(model, "SEG_MODEL_SHA256", hashlib.sha256(b"model").hexdigest())
32
+ return model, archive
33
+
34
+
35
+ def test_model_download_is_cached_outside_package(distribution):
36
+ model, archive = distribution
37
+ directory = model.download_seg_model()
38
+ assert directory.name == "4.6.0-alpha"
39
+ assert (directory / "dccc_seg.onnx").read_bytes() == b"model"
40
+ archive.unlink()
41
+ assert model.download_seg_model() == directory
42
+
43
+
44
+ @pytest.mark.parametrize("kind", ["digest", "traversal", "missing"])
45
+ def test_model_download_rejects_bad_assets(distribution, kind, tmp_path):
46
+ model, archive = distribution
47
+ with zipfile.ZipFile(archive, "w") as z:
48
+ for name in FILES:
49
+ if kind == "missing" and name == "labels.json":
50
+ continue
51
+ z.writestr("seg/" + name, b"wrong" if kind == "digest" else
52
+ (b"model" if name.endswith(".onnx") else b"{}"))
53
+ if kind == "traversal":
54
+ z.writestr("../escaped", b"bad")
55
+ with pytest.raises(model.SegModelDownloadError):
56
+ model.download_seg_model()
57
+ assert not (tmp_path / "escaped").exists()
58
+ assert not model.seg_model_dir().exists()
59
+
60
+
61
+ def test_bundled_model_and_download_opt_out(distribution, monkeypatch, tmp_path):
62
+ model, archive = distribution
63
+ exe = tmp_path / "native" / "DCCCcore"
64
+ bundle = exe.parent / "assets/models/seg"
65
+ bundle.mkdir(parents=True)
66
+ for name in FILES:
67
+ (bundle / name).write_bytes(b"model" if name.endswith(".onnx") else b"{}")
68
+ monkeypatch.setenv("DCCCPY_AUTO_DOWNLOAD", "0")
69
+ assert model.default_seg_bundle(exe) is None
70
+ (bundle / "dccc_seg.onnx").unlink()
71
+ with pytest.raises(model.SegModelDownloadError, match="download"):
72
+ model.default_seg_bundle(exe)
73
+ cached = model.download_seg_model() # Explicit provisioning honors user's call.
74
+ assert model.default_seg_bundle(exe) == cached
75
+
76
+
77
+ def test_native_symlink_reuses_real_bundled_model(distribution, monkeypatch, tmp_path):
78
+ model, _ = distribution
79
+ native = tmp_path / "native/DCCCcore"
80
+ bundle = native.parent / "assets/models/seg"
81
+ bundle.mkdir(parents=True)
82
+ native.write_text("native executable")
83
+ for name in FILES:
84
+ (bundle / name).write_bytes(b"model" if name.endswith(".onnx") else b"{}")
85
+ link = tmp_path / "bin/DCCCcore"
86
+ link.parent.mkdir()
87
+ link.symlink_to(native)
88
+ monkeypatch.setenv("DCCCPY_AUTO_DOWNLOAD", "0")
89
+ assert model.default_seg_bundle(link) is None
90
+
91
+
92
+ @pytest.mark.parametrize("package", ["linux", "linux-arm64", "macos", "windows"])
93
+ def test_every_runtime_profile_excludes_seg(tmp_path, package):
94
+ root = Path(__file__).resolve().parents[3]
95
+ vendor = import_script(root / f"python/dcccpy-{package}-runtime/scripts/vendor_dccccore.py")
96
+ for profile in vendor.EXCLUDE_PROFILES:
97
+ dest = tmp_path / profile
98
+ model = dest / "assets/models/seg/dccc_seg.onnx"
99
+ rigid = dest / "assets/models/registration/rigid.onnx"
100
+ model.parent.mkdir(parents=True)
101
+ rigid.parent.mkdir(parents=True)
102
+ model.write_bytes(b"bulky optional segmentation")
103
+ rigid.write_bytes(b"required rigid")
104
+ vendor.prune_profile(dest, profile)
105
+ assert not model.exists()
106
+ assert rigid.exists()
107
+
108
+
109
+ def test_model_release_zip_contains_runtime_controls(tmp_path):
110
+ root = Path(__file__).resolve().parents[3]
111
+ packer = import_script(root / "scripts/package-seg-model.py")
112
+ bundle = tmp_path / "source"
113
+ bundle.mkdir()
114
+ for name in FILES:
115
+ (bundle / name).write_bytes(b"model" if name.endswith(".onnx") else b"{}")
116
+ archive = packer.package_model(bundle, tmp_path / "dist", "4.6.0-alpha")
117
+ assert archive.name == "DCCCseg-4.6.0-alpha.zip"
118
+ with zipfile.ZipFile(archive) as z:
119
+ assert set(z.namelist()) == {"seg/" + name for name in FILES}
120
+
121
+
122
+ def test_model_release_preserves_reference_entrypoint(tmp_path):
123
+ root = Path(__file__).resolve().parents[3]
124
+ packer = import_script(root / "scripts/package-seg-model.py")
125
+ bundle = tmp_path / "source"
126
+ bundle.mkdir()
127
+ for name in FILES:
128
+ (bundle / name).write_bytes(b"model" if name.endswith(".onnx") else b"{}")
129
+ (bundle / "README.md").write_text("See dccc_seg.py for reference inference")
130
+ (bundle / "dccc_seg.py").write_text("# shipped reference entry point\n")
131
+ archive = packer.package_model(bundle, tmp_path / "dist", "4.6.0-alpha")
132
+ with zipfile.ZipFile(archive) as z:
133
+ assert z.read("seg/dccc_seg.py") == b"# shipped reference entry point\n"
134
+
135
+
136
+ def test_release_version_must_match_source():
137
+ root = Path(__file__).resolve().parents[3]
138
+ metadata = import_script(root / "scripts/release-metadata.py")
139
+ cmake = 'project(DCCCcore VERSION 4.6.0)\nset(DCCCCORE_VERSION_SUFFIX "-alpha")\n'
140
+ assert metadata.release_version(cmake, requested="4.6.0-alpha") == "4.6.0-alpha"
141
+ assert metadata.release_version(cmake, tag="v4.6.0-alpha") == "4.6.0-alpha"
142
+ with pytest.raises(ValueError):
143
+ metadata.release_version(cmake, requested="4.5.1-alpha")
144
+ with pytest.raises(ValueError):
145
+ metadata.release_version(cmake, tag="v4.6.0")
File without changes
File without changes
File without changes
File without changes
File without changes