dcccpy 0.1.2__py3-none-any.whl
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.
- dcccpy/__init__.py +42 -0
- dcccpy/cli.py +21 -0
- dcccpy/core.py +361 -0
- dcccpy/py.typed +1 -0
- dcccpy/runtime.py +237 -0
- dcccpy-0.1.2.dist-info/METADATA +152 -0
- dcccpy-0.1.2.dist-info/RECORD +10 -0
- dcccpy-0.1.2.dist-info/WHEEL +5 -0
- dcccpy-0.1.2.dist-info/entry_points.txt +2 -0
- dcccpy-0.1.2.dist-info/top_level.txt +1 -0
dcccpy/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Python wrapper for the native DCCCcore executable."""
|
|
2
|
+
|
|
3
|
+
from .core import (
|
|
4
|
+
DCCCResult,
|
|
5
|
+
adad,
|
|
6
|
+
abetaindex,
|
|
7
|
+
abetaload,
|
|
8
|
+
adni_pet_core,
|
|
9
|
+
centaur,
|
|
10
|
+
centaurz,
|
|
11
|
+
centiloid,
|
|
12
|
+
dccccore_path,
|
|
13
|
+
fillstates,
|
|
14
|
+
normalize,
|
|
15
|
+
parse_metrics,
|
|
16
|
+
rigid,
|
|
17
|
+
run,
|
|
18
|
+
suvr,
|
|
19
|
+
)
|
|
20
|
+
from .runtime import DCCCCORE_VERSION, DCCCcoreDownloadError, DCCCcoreNotFoundError, download_dccccore
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"DCCCResult",
|
|
24
|
+
"DCCCCORE_VERSION",
|
|
25
|
+
"DCCCcoreDownloadError",
|
|
26
|
+
"DCCCcoreNotFoundError",
|
|
27
|
+
"adad",
|
|
28
|
+
"abetaindex",
|
|
29
|
+
"abetaload",
|
|
30
|
+
"adni_pet_core",
|
|
31
|
+
"centaur",
|
|
32
|
+
"centaurz",
|
|
33
|
+
"centiloid",
|
|
34
|
+
"dccccore_path",
|
|
35
|
+
"download_dccccore",
|
|
36
|
+
"fillstates",
|
|
37
|
+
"normalize",
|
|
38
|
+
"parse_metrics",
|
|
39
|
+
"rigid",
|
|
40
|
+
"run",
|
|
41
|
+
"suvr",
|
|
42
|
+
]
|
dcccpy/cli.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .core import run
|
|
6
|
+
from .runtime import DCCCcoreDownloadError, DCCCcoreNotFoundError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main(argv: list[str] | None = None) -> int:
|
|
10
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
11
|
+
try:
|
|
12
|
+
result = run(args, check=False)
|
|
13
|
+
except (DCCCcoreDownloadError, DCCCcoreNotFoundError) as exc:
|
|
14
|
+
print(str(exc), file=sys.stderr)
|
|
15
|
+
return 127
|
|
16
|
+
|
|
17
|
+
if result.stdout:
|
|
18
|
+
print(result.stdout, end="")
|
|
19
|
+
if result.stderr:
|
|
20
|
+
print(result.stderr, file=sys.stderr, end="")
|
|
21
|
+
return result.returncode
|
dcccpy/core.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Mapping, Sequence
|
|
10
|
+
|
|
11
|
+
from .runtime import DCCCcoreNotFoundError, dccccore_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_NUMBER_RE = r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?"
|
|
15
|
+
_METRIC_RE = re.compile(rf"^\s*([A-Za-z][A-Za-z0-9_. /()-]*):\s*({_NUMBER_RE})\s*(?:%.*)?$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class DCCCResult:
|
|
20
|
+
"""Completed DCCCcore invocation."""
|
|
21
|
+
|
|
22
|
+
args: tuple[str, ...]
|
|
23
|
+
returncode: int
|
|
24
|
+
stdout: str
|
|
25
|
+
stderr: str
|
|
26
|
+
output: Path | None = None
|
|
27
|
+
temp_dir: Path | None = None
|
|
28
|
+
executable: Path | None = None
|
|
29
|
+
metrics: Mapping[str, float] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def command(self) -> tuple[str, ...]:
|
|
33
|
+
if self.executable is None:
|
|
34
|
+
return self.args
|
|
35
|
+
return (str(self.executable), *self.args)
|
|
36
|
+
|
|
37
|
+
def check_returncode(self) -> "DCCCResult":
|
|
38
|
+
if self.returncode:
|
|
39
|
+
raise subprocess.CalledProcessError(
|
|
40
|
+
self.returncode,
|
|
41
|
+
list(self.command),
|
|
42
|
+
output=self.stdout,
|
|
43
|
+
stderr=self.stderr,
|
|
44
|
+
)
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def load_output(self):
|
|
48
|
+
"""Load the output image with nibabel."""
|
|
49
|
+
|
|
50
|
+
if self.output is None:
|
|
51
|
+
raise ValueError("This DCCCResult does not have an output path.")
|
|
52
|
+
try:
|
|
53
|
+
import nibabel as nib
|
|
54
|
+
except ImportError as exc:
|
|
55
|
+
raise ImportError("nibabel is required to load output images.") from exc
|
|
56
|
+
return nib.load(os.fspath(self.output))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_metrics(text: str) -> dict[str, float]:
|
|
60
|
+
"""Parse numeric metric lines from DCCCcore stdout."""
|
|
61
|
+
|
|
62
|
+
metrics: dict[str, float] = {}
|
|
63
|
+
for line in text.splitlines():
|
|
64
|
+
match = _METRIC_RE.match(line)
|
|
65
|
+
if not match:
|
|
66
|
+
continue
|
|
67
|
+
label = " ".join(match.group(1).strip().split())
|
|
68
|
+
metrics[label] = float(match.group(2))
|
|
69
|
+
return metrics
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _as_args(args: Sequence[object]) -> list[str]:
|
|
73
|
+
return [os.fspath(arg) if isinstance(arg, os.PathLike) else str(arg) for arg in args]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run(
|
|
77
|
+
args: Sequence[object],
|
|
78
|
+
*,
|
|
79
|
+
check: bool = True,
|
|
80
|
+
executable: str | os.PathLike[str] | None = None,
|
|
81
|
+
cwd: str | os.PathLike[str] | None = None,
|
|
82
|
+
env: Mapping[str, str] | None = None,
|
|
83
|
+
output: str | os.PathLike[str] | None = None,
|
|
84
|
+
) -> DCCCResult:
|
|
85
|
+
"""Run DCCCcore with raw command-line arguments."""
|
|
86
|
+
|
|
87
|
+
exe = dccccore_path(executable)
|
|
88
|
+
str_args = _as_args(args)
|
|
89
|
+
completed = subprocess.run(
|
|
90
|
+
[str(exe), *str_args],
|
|
91
|
+
cwd=os.fspath(cwd) if cwd is not None else None,
|
|
92
|
+
env={**os.environ, **dict(env or {})},
|
|
93
|
+
text=True,
|
|
94
|
+
capture_output=True,
|
|
95
|
+
check=False,
|
|
96
|
+
)
|
|
97
|
+
result = DCCCResult(
|
|
98
|
+
args=tuple(str_args),
|
|
99
|
+
returncode=completed.returncode,
|
|
100
|
+
stdout=completed.stdout,
|
|
101
|
+
stderr=completed.stderr,
|
|
102
|
+
output=Path(output) if output is not None else None,
|
|
103
|
+
executable=exe,
|
|
104
|
+
metrics=parse_metrics(completed.stdout),
|
|
105
|
+
)
|
|
106
|
+
return result.check_returncode() if check else result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _temp_output(suffix: str = ".nii") -> tuple[Path, Path]:
|
|
110
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="dcccpy-"))
|
|
111
|
+
return temp_dir / f"output{suffix}", temp_dir
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _create_temp_dir(existing: Path | None = None) -> Path:
|
|
115
|
+
return existing or Path(tempfile.mkdtemp(prefix="dcccpy-"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _resolve_user_path(path: str | os.PathLike[str]) -> Path:
|
|
119
|
+
return Path(path).expanduser().resolve(strict=False)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _prepare_input(input: object, temp_dir: Path | None) -> tuple[str, Path | None]:
|
|
123
|
+
if isinstance(input, (str, os.PathLike)):
|
|
124
|
+
return os.fspath(_resolve_user_path(input)), temp_dir
|
|
125
|
+
|
|
126
|
+
to_filename = getattr(input, "to_filename", None)
|
|
127
|
+
if callable(to_filename):
|
|
128
|
+
temp_dir = _create_temp_dir(temp_dir)
|
|
129
|
+
input_path = temp_dir / "input.nii.gz"
|
|
130
|
+
to_filename(os.fspath(input_path))
|
|
131
|
+
return os.fspath(input_path), temp_dir
|
|
132
|
+
|
|
133
|
+
raise TypeError(
|
|
134
|
+
"input must be a filesystem path or a nibabel-like image object with to_filename()."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _prepare_io(input: object, output: str | os.PathLike[str] | None) -> tuple[str, Path, Path | None]:
|
|
139
|
+
temp_dir: Path | None = None
|
|
140
|
+
if output is None:
|
|
141
|
+
actual_output, temp_dir = _temp_output()
|
|
142
|
+
else:
|
|
143
|
+
actual_output = _resolve_user_path(output)
|
|
144
|
+
|
|
145
|
+
input_path, temp_dir = _prepare_input(input, temp_dir)
|
|
146
|
+
return input_path, actual_output, temp_dir
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _append_flag(args: list[str], flag: str, enabled: bool) -> None:
|
|
150
|
+
if enabled:
|
|
151
|
+
args.append(flag)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _metric_command(
|
|
155
|
+
subcommand: str,
|
|
156
|
+
input: object,
|
|
157
|
+
output: str | os.PathLike[str] | None,
|
|
158
|
+
*,
|
|
159
|
+
batch: bool = False,
|
|
160
|
+
bids: str | None = None,
|
|
161
|
+
config: str | os.PathLike[str] | None = None,
|
|
162
|
+
skip_normalization: bool = False,
|
|
163
|
+
suvr_values: bool = False,
|
|
164
|
+
debug: bool = False,
|
|
165
|
+
extra_args: Sequence[object] = (),
|
|
166
|
+
check: bool = True,
|
|
167
|
+
executable: str | os.PathLike[str] | None = None,
|
|
168
|
+
) -> DCCCResult:
|
|
169
|
+
input_path, actual_output, temp_dir = _prepare_io(input, output)
|
|
170
|
+
|
|
171
|
+
args = [subcommand, "--input", input_path, "--output", os.fspath(actual_output)]
|
|
172
|
+
_append_flag(args, "--batch", batch)
|
|
173
|
+
_append_flag(args, "--skip-normalization", skip_normalization)
|
|
174
|
+
_append_flag(args, "--suvr", suvr_values)
|
|
175
|
+
_append_flag(args, "--debug", debug)
|
|
176
|
+
if bids is not None:
|
|
177
|
+
args.extend(["--bids", bids])
|
|
178
|
+
if config is not None:
|
|
179
|
+
args.extend(["--config", os.fspath(config)])
|
|
180
|
+
args.extend(_as_args(extra_args))
|
|
181
|
+
|
|
182
|
+
result = run(args, check=check, executable=executable, output=actual_output)
|
|
183
|
+
return DCCCResult(
|
|
184
|
+
args=result.args,
|
|
185
|
+
returncode=result.returncode,
|
|
186
|
+
stdout=result.stdout,
|
|
187
|
+
stderr=result.stderr,
|
|
188
|
+
output=actual_output,
|
|
189
|
+
temp_dir=temp_dir,
|
|
190
|
+
executable=result.executable,
|
|
191
|
+
metrics=result.metrics,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _coerce_metric_kwargs(kwargs: dict[str, object], *, suvr: bool = False) -> dict[str, object]:
|
|
196
|
+
coerced = dict(kwargs)
|
|
197
|
+
if "include_suvr" in coerced and "suvr_values" not in coerced:
|
|
198
|
+
coerced["suvr_values"] = coerced.pop("include_suvr")
|
|
199
|
+
if "suvr" in coerced and "suvr_values" not in coerced:
|
|
200
|
+
coerced["suvr_values"] = coerced.pop("suvr")
|
|
201
|
+
if suvr and "suvr_values" not in coerced:
|
|
202
|
+
coerced["suvr_values"] = True
|
|
203
|
+
return coerced
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def centiloid(
|
|
207
|
+
input: object,
|
|
208
|
+
output: str | os.PathLike[str] | None = None,
|
|
209
|
+
*,
|
|
210
|
+
suvr: bool = False,
|
|
211
|
+
**kwargs: object,
|
|
212
|
+
) -> DCCCResult:
|
|
213
|
+
"""Run the DCCCcore centiloid command."""
|
|
214
|
+
|
|
215
|
+
return _metric_command("centiloid", input, output, **_coerce_metric_kwargs(kwargs, suvr=suvr))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def centaur(
|
|
219
|
+
input: object,
|
|
220
|
+
output: str | os.PathLike[str] | None = None,
|
|
221
|
+
*,
|
|
222
|
+
suvr: bool = False,
|
|
223
|
+
**kwargs: object,
|
|
224
|
+
) -> DCCCResult:
|
|
225
|
+
return _metric_command("centaur", input, output, **_coerce_metric_kwargs(kwargs, suvr=suvr))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def centaurz(
|
|
229
|
+
input: object,
|
|
230
|
+
output: str | os.PathLike[str] | None = None,
|
|
231
|
+
*,
|
|
232
|
+
suvr: bool = False,
|
|
233
|
+
report_detailed_regions: bool = False,
|
|
234
|
+
**kwargs: object,
|
|
235
|
+
) -> DCCCResult:
|
|
236
|
+
coerced = _coerce_metric_kwargs(kwargs, suvr=suvr)
|
|
237
|
+
extra_args = list(coerced.pop("extra_args", ()))
|
|
238
|
+
if report_detailed_regions:
|
|
239
|
+
extra_args.append("--report-detailed-regions")
|
|
240
|
+
return _metric_command("centaurz", input, output, extra_args=extra_args, **coerced)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def abetaindex(
|
|
244
|
+
input: object,
|
|
245
|
+
output: str | os.PathLike[str] | None = None,
|
|
246
|
+
**kwargs: object,
|
|
247
|
+
) -> DCCCResult:
|
|
248
|
+
return _metric_command("abetaindex", input, output, **_coerce_metric_kwargs(kwargs))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def abetaload(
|
|
252
|
+
input: object,
|
|
253
|
+
output: str | os.PathLike[str] | None = None,
|
|
254
|
+
**kwargs: object,
|
|
255
|
+
) -> DCCCResult:
|
|
256
|
+
return _metric_command("abetaload", input, output, **_coerce_metric_kwargs(kwargs))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def fillstates(
|
|
260
|
+
input: object,
|
|
261
|
+
output: str | os.PathLike[str] | None = None,
|
|
262
|
+
*,
|
|
263
|
+
tracer: str,
|
|
264
|
+
suvr: bool = False,
|
|
265
|
+
**kwargs: object,
|
|
266
|
+
) -> DCCCResult:
|
|
267
|
+
coerced = _coerce_metric_kwargs(kwargs, suvr=suvr)
|
|
268
|
+
extra_args = list(coerced.pop("extra_args", ()))
|
|
269
|
+
extra_args.extend(["--tracer", tracer])
|
|
270
|
+
return _metric_command("fillstates", input, output, extra_args=extra_args, **coerced)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def suvr(
|
|
274
|
+
input: object,
|
|
275
|
+
output: str | os.PathLike[str] | None = None,
|
|
276
|
+
*,
|
|
277
|
+
voi_mask: str | os.PathLike[str],
|
|
278
|
+
ref_mask: str | os.PathLike[str],
|
|
279
|
+
**kwargs: object,
|
|
280
|
+
) -> DCCCResult:
|
|
281
|
+
coerced = _coerce_metric_kwargs(kwargs)
|
|
282
|
+
extra_args = list(coerced.pop("extra_args", ()))
|
|
283
|
+
extra_args.extend(["--voi-mask", os.fspath(voi_mask), "--ref-mask", os.fspath(ref_mask)])
|
|
284
|
+
return _metric_command("suvr", input, output, extra_args=extra_args, **coerced)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def adad(
|
|
288
|
+
input: object,
|
|
289
|
+
output: str | os.PathLike[str] | None = None,
|
|
290
|
+
*,
|
|
291
|
+
modality: str | None = None,
|
|
292
|
+
iterative: bool = False,
|
|
293
|
+
**kwargs: object,
|
|
294
|
+
) -> DCCCResult:
|
|
295
|
+
coerced = _coerce_metric_kwargs(kwargs)
|
|
296
|
+
extra_args = list(coerced.pop("extra_args", ()))
|
|
297
|
+
if modality is not None:
|
|
298
|
+
extra_args.extend(["--modality", modality])
|
|
299
|
+
if iterative:
|
|
300
|
+
extra_args.append("--iterative")
|
|
301
|
+
return _metric_command("adad", input, output, extra_args=extra_args, **coerced)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _spatial_command(
|
|
305
|
+
subcommand: str,
|
|
306
|
+
input: object,
|
|
307
|
+
output: str | os.PathLike[str] | None = None,
|
|
308
|
+
*,
|
|
309
|
+
iterative: bool = False,
|
|
310
|
+
manual_fov: bool = False,
|
|
311
|
+
batch: bool = False,
|
|
312
|
+
bids: str | None = None,
|
|
313
|
+
extra_args: Sequence[object] = (),
|
|
314
|
+
check: bool = True,
|
|
315
|
+
executable: str | os.PathLike[str] | None = None,
|
|
316
|
+
) -> DCCCResult:
|
|
317
|
+
input_path, actual_output, temp_dir = _prepare_io(input, output)
|
|
318
|
+
|
|
319
|
+
args = [subcommand, "--input", input_path, "--output", os.fspath(actual_output)]
|
|
320
|
+
_append_flag(args, "--iterative", iterative)
|
|
321
|
+
_append_flag(args, "--manual-fov", manual_fov)
|
|
322
|
+
_append_flag(args, "--batch", batch)
|
|
323
|
+
if bids is not None:
|
|
324
|
+
args.extend(["--bids", bids])
|
|
325
|
+
args.extend(_as_args(extra_args))
|
|
326
|
+
|
|
327
|
+
result = run(args, check=check, executable=executable, output=actual_output)
|
|
328
|
+
return DCCCResult(
|
|
329
|
+
args=result.args,
|
|
330
|
+
returncode=result.returncode,
|
|
331
|
+
stdout=result.stdout,
|
|
332
|
+
stderr=result.stderr,
|
|
333
|
+
output=actual_output,
|
|
334
|
+
temp_dir=temp_dir,
|
|
335
|
+
executable=result.executable,
|
|
336
|
+
metrics=result.metrics,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def normalize(
|
|
341
|
+
input: object,
|
|
342
|
+
output: str | os.PathLike[str] | None = None,
|
|
343
|
+
**kwargs: object,
|
|
344
|
+
) -> DCCCResult:
|
|
345
|
+
return _spatial_command("normalize", input, output, **kwargs)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def adni_pet_core(
|
|
349
|
+
input: object,
|
|
350
|
+
output: str | os.PathLike[str] | None = None,
|
|
351
|
+
**kwargs: object,
|
|
352
|
+
) -> DCCCResult:
|
|
353
|
+
return _spatial_command("adni-pet-core", input, output, **kwargs)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def rigid(
|
|
357
|
+
input: object,
|
|
358
|
+
output: str | os.PathLike[str] | None = None,
|
|
359
|
+
**kwargs: object,
|
|
360
|
+
) -> DCCCResult:
|
|
361
|
+
return _spatial_command("rigid", input, output, **kwargs)
|
dcccpy/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
dcccpy/runtime.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import urllib.request
|
|
10
|
+
import zipfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DCCCCORE_VERSION = "4.2.3"
|
|
16
|
+
RELEASE_REPO = "tctco/DCCCSlicer"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DCCCcoreNotFoundError(FileNotFoundError):
|
|
20
|
+
"""Raised when dcccpy cannot locate a DCCCcore executable."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DCCCcoreDownloadError(RuntimeError):
|
|
24
|
+
"""Raised when automatic DCCCcore download fails."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def platform_key() -> str:
|
|
28
|
+
machine = platform.machine().lower()
|
|
29
|
+
if machine in {"x86_64", "amd64"}:
|
|
30
|
+
arch = "x86_64"
|
|
31
|
+
elif machine in {"aarch64", "arm64"}:
|
|
32
|
+
arch = "arm64"
|
|
33
|
+
else:
|
|
34
|
+
arch = machine
|
|
35
|
+
|
|
36
|
+
if sys.platform.startswith("linux"):
|
|
37
|
+
return f"linux-{arch}"
|
|
38
|
+
if sys.platform == "darwin":
|
|
39
|
+
return f"macos-{arch}"
|
|
40
|
+
if sys.platform.startswith("win"):
|
|
41
|
+
return f"windows-{arch}"
|
|
42
|
+
return f"{sys.platform}-{arch}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def release_platform(key: str | None = None) -> str:
|
|
46
|
+
key = key or platform_key()
|
|
47
|
+
mapping = {
|
|
48
|
+
"linux-x86_64": "ubuntu-latest-x64",
|
|
49
|
+
"windows-x86_64": "windows-latest-x64",
|
|
50
|
+
"macos-arm64": "macos-latest-arm64",
|
|
51
|
+
}
|
|
52
|
+
try:
|
|
53
|
+
return mapping[key]
|
|
54
|
+
except KeyError as exc:
|
|
55
|
+
raise DCCCcoreNotFoundError(f"No prebuilt DCCCcore release asset is known for {key}.") from exc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def executable_names() -> tuple[str, ...]:
|
|
59
|
+
return ("DCCCcore.exe", "DCCCcore") if os.name == "nt" else ("DCCCcore",)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def package_root() -> Path:
|
|
63
|
+
return Path(__file__).resolve().parent
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cache_root() -> Path:
|
|
67
|
+
explicit = os.environ.get("DCCCPY_CACHE_DIR")
|
|
68
|
+
if explicit:
|
|
69
|
+
return Path(explicit).expanduser()
|
|
70
|
+
|
|
71
|
+
if sys.platform.startswith("win"):
|
|
72
|
+
base = os.environ.get("LOCALAPPDATA")
|
|
73
|
+
if base:
|
|
74
|
+
return Path(base) / "dcccpy"
|
|
75
|
+
|
|
76
|
+
base = os.environ.get("XDG_CACHE_HOME")
|
|
77
|
+
if base:
|
|
78
|
+
return Path(base) / "dcccpy"
|
|
79
|
+
|
|
80
|
+
return Path.home() / ".cache" / "dcccpy"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def runtime_dir(version: str = DCCCCORE_VERSION, key: str | None = None) -> Path:
|
|
84
|
+
return cache_root() / "dccccore" / version / (key or platform_key())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def candidate_executables(base_dirs: Iterable[Path]) -> list[Path]:
|
|
88
|
+
candidates: list[Path] = []
|
|
89
|
+
for base in base_dirs:
|
|
90
|
+
for name in executable_names():
|
|
91
|
+
candidates.append(base / name)
|
|
92
|
+
return candidates
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _main_package_vendor_dirs() -> list[Path]:
|
|
96
|
+
vendor_root = package_root() / "vendor" / "dccccore"
|
|
97
|
+
key = platform_key()
|
|
98
|
+
return [vendor_root / key, vendor_root]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _runtime_package_dirs() -> list[Path]:
|
|
102
|
+
try:
|
|
103
|
+
import dcccpy_linux_runtime
|
|
104
|
+
except Exception:
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
root = dcccpy_linux_runtime.dccccore_root()
|
|
108
|
+
return [Path(root)]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _cached_dirs(version: str = DCCCCORE_VERSION) -> list[Path]:
|
|
112
|
+
return [runtime_dir(version)]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def find_existing_dccccore(version: str = DCCCCORE_VERSION) -> Path | None:
|
|
116
|
+
for candidate in candidate_executables(_main_package_vendor_dirs()):
|
|
117
|
+
if candidate.exists():
|
|
118
|
+
return candidate
|
|
119
|
+
|
|
120
|
+
for candidate in candidate_executables(_runtime_package_dirs()):
|
|
121
|
+
if candidate.exists():
|
|
122
|
+
return candidate
|
|
123
|
+
|
|
124
|
+
for candidate in candidate_executables(_cached_dirs(version)):
|
|
125
|
+
if candidate.exists():
|
|
126
|
+
return candidate
|
|
127
|
+
|
|
128
|
+
found = shutil.which("DCCCcore")
|
|
129
|
+
return Path(found) if found else None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def auto_download_enabled() -> bool:
|
|
133
|
+
value = os.environ.get("DCCCPY_AUTO_DOWNLOAD", "1").strip().lower()
|
|
134
|
+
return value not in {"0", "false", "no", "off"}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def release_url(version: str = DCCCCORE_VERSION, repo: str = RELEASE_REPO, platform_name: str | None = None) -> str:
|
|
138
|
+
platform_name = platform_name or release_platform()
|
|
139
|
+
asset = f"DCCCcore-{version}-{platform_name}.zip"
|
|
140
|
+
return f"https://github.com/{repo}/releases/download/v{version}/{asset}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _safe_extract(zip_file: zipfile.ZipFile, destination: Path) -> None:
|
|
144
|
+
destination = destination.resolve()
|
|
145
|
+
for member in zip_file.infolist():
|
|
146
|
+
target = (destination / member.filename).resolve()
|
|
147
|
+
if not str(target).startswith(str(destination)):
|
|
148
|
+
raise DCCCcoreDownloadError(f"Unsafe path in DCCCcore archive: {member.filename}")
|
|
149
|
+
zip_file.extractall(destination)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _copy_runtime_tree(extracted_root: Path, destination: Path) -> None:
|
|
153
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
if destination.exists():
|
|
155
|
+
shutil.rmtree(destination)
|
|
156
|
+
shutil.copytree(extracted_root, destination)
|
|
157
|
+
|
|
158
|
+
for candidate in candidate_executables([destination]):
|
|
159
|
+
if candidate.exists() and os.name != "nt":
|
|
160
|
+
mode = candidate.stat().st_mode
|
|
161
|
+
candidate.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def download_dccccore(
|
|
165
|
+
*,
|
|
166
|
+
version: str = DCCCCORE_VERSION,
|
|
167
|
+
repo: str | None = None,
|
|
168
|
+
key: str | None = None,
|
|
169
|
+
force: bool = False,
|
|
170
|
+
) -> Path:
|
|
171
|
+
key = key or platform_key()
|
|
172
|
+
destination = runtime_dir(version, key)
|
|
173
|
+
existing = next((path for path in candidate_executables([destination]) if path.exists()), None)
|
|
174
|
+
if existing is not None and not force:
|
|
175
|
+
return existing
|
|
176
|
+
|
|
177
|
+
repo = repo or os.environ.get("DCCCPY_RELEASE_REPO", RELEASE_REPO)
|
|
178
|
+
url = os.environ.get("DCCCPY_DCCCCORE_URL") or release_url(
|
|
179
|
+
version=version,
|
|
180
|
+
repo=repo,
|
|
181
|
+
platform_name=release_platform(key),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
with tempfile.TemporaryDirectory(prefix="dcccpy-runtime-") as temp_name:
|
|
185
|
+
temp = Path(temp_name)
|
|
186
|
+
archive = temp / "DCCCcore.zip"
|
|
187
|
+
try:
|
|
188
|
+
with urllib.request.urlopen(url) as response, archive.open("wb") as handle:
|
|
189
|
+
shutil.copyfileobj(response, handle)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
raise DCCCcoreDownloadError(f"Failed to download DCCCcore from {url}") from exc
|
|
192
|
+
|
|
193
|
+
extract_dir = temp / "extract"
|
|
194
|
+
extract_dir.mkdir()
|
|
195
|
+
try:
|
|
196
|
+
with zipfile.ZipFile(archive) as zf:
|
|
197
|
+
_safe_extract(zf, extract_dir)
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
raise DCCCcoreDownloadError(f"Failed to extract DCCCcore archive from {url}") from exc
|
|
200
|
+
|
|
201
|
+
roots = [path for path in extract_dir.iterdir() if path.is_dir()]
|
|
202
|
+
if len(roots) != 1:
|
|
203
|
+
raise DCCCcoreDownloadError(f"Expected one top-level directory in DCCCcore archive, found {len(roots)}.")
|
|
204
|
+
|
|
205
|
+
_copy_runtime_tree(roots[0], destination)
|
|
206
|
+
|
|
207
|
+
existing = next((path for path in candidate_executables([destination]) if path.exists()), None)
|
|
208
|
+
if existing is None:
|
|
209
|
+
raise DCCCcoreDownloadError(f"DCCCcore archive did not contain an executable for {key}.")
|
|
210
|
+
return existing
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def dccccore_path(
|
|
214
|
+
executable: str | os.PathLike[str] | None = None,
|
|
215
|
+
*,
|
|
216
|
+
allow_download: bool | None = None,
|
|
217
|
+
version: str = DCCCCORE_VERSION,
|
|
218
|
+
) -> Path:
|
|
219
|
+
explicit = executable or os.environ.get("DCCCPY_DCCCCORE")
|
|
220
|
+
if explicit:
|
|
221
|
+
path = Path(explicit).expanduser()
|
|
222
|
+
if path.exists():
|
|
223
|
+
return path
|
|
224
|
+
raise DCCCcoreNotFoundError(f"DCCCcore executable does not exist: {path}")
|
|
225
|
+
|
|
226
|
+
existing = find_existing_dccccore(version)
|
|
227
|
+
if existing is not None:
|
|
228
|
+
return existing
|
|
229
|
+
|
|
230
|
+
should_download = auto_download_enabled() if allow_download is None else allow_download
|
|
231
|
+
if should_download:
|
|
232
|
+
return download_dccccore(version=version)
|
|
233
|
+
|
|
234
|
+
raise DCCCcoreNotFoundError(
|
|
235
|
+
"Could not find DCCCcore. Install dcccpy[linux-runtime], set DCCCPY_DCCCCORE, "
|
|
236
|
+
"put DCCCcore on PATH, or enable automatic download with DCCCPY_AUTO_DOWNLOAD=1."
|
|
237
|
+
)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dcccpy
|
|
3
|
+
Version: 0.1.2
|
|
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.1.0; extra == "linux-runtime"
|
|
21
|
+
|
|
22
|
+
# dcccpy
|
|
23
|
+
|
|
24
|
+
`dcccpy` is a Python wrapper for the `DCCCcore` command-line tool from
|
|
25
|
+
DCCCSlicer. It keeps the native C++ core as the execution engine and provides a
|
|
26
|
+
small Python API for running common PET biomarker workflows.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
The default package is slim:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install dcccpy
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The first call downloads the matching `DCCCcore` release package into the local
|
|
37
|
+
user cache if no native runtime is otherwise available. The slim package also
|
|
38
|
+
installs `nibabel` for image loading and nibabel-style image inputs.
|
|
39
|
+
|
|
40
|
+
For Linux users who prefer `pip install` to include the native runtime and avoid
|
|
41
|
+
first-run download:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install "dcccpy[linux-runtime]"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Python API
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import dcccpy
|
|
51
|
+
|
|
52
|
+
result = dcccpy.centiloid("amyloid_pet.nii", skip_normalization=True)
|
|
53
|
+
print(result.metrics)
|
|
54
|
+
print(result.output)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The `output` argument is optional for single-image Python calls. When omitted,
|
|
58
|
+
`dcccpy` creates a temporary output NIfTI path and returns it in the result.
|
|
59
|
+
Relative input and output paths are resolved from the current Python working
|
|
60
|
+
directory before `DCCCcore` is invoked.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
result = dcccpy.centiloid("amyloid_pet.nii")
|
|
64
|
+
print(result.output) # temporary output path
|
|
65
|
+
print(result.metrics.get("fbp"))
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The wrapper accepts nibabel-style image objects with `to_filename()`:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import nibabel as nib
|
|
72
|
+
import dcccpy
|
|
73
|
+
|
|
74
|
+
image = nib.load("amyloid_pet.nii.gz")
|
|
75
|
+
result = dcccpy.centiloid(image, skip_normalization=True)
|
|
76
|
+
output_image = result.load_output()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Common helpers mirror `DCCCcore` subcommands:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
dcccpy.centiloid("amyloid.nii", suvr=True)
|
|
83
|
+
dcccpy.centaurz("tau.nii", report_detailed_regions=True)
|
|
84
|
+
dcccpy.fillstates("fdg.nii", tracer="fdg")
|
|
85
|
+
dcccpy.normalize("pet.nii", iterative=True)
|
|
86
|
+
dcccpy.run(["centiloid", "--input", "a.nii", "--output", "b.nii"])
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Each helper returns `DCCCResult` with:
|
|
90
|
+
|
|
91
|
+
- `returncode`
|
|
92
|
+
- `stdout` / `stderr`
|
|
93
|
+
- `output`
|
|
94
|
+
- `metrics`, parsed from numeric lines in stdout
|
|
95
|
+
- `load_output()`, which loads the output with nibabel
|
|
96
|
+
|
|
97
|
+
## Command Line
|
|
98
|
+
|
|
99
|
+
`dcccpy` also forwards raw arguments to `DCCCcore`:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
dcccpy --help
|
|
103
|
+
dcccpy centiloid --input amyloid_pet.nii --output result.nii
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Runtime lookup
|
|
107
|
+
|
|
108
|
+
At runtime, `dcccpy` looks for `DCCCcore` in this order:
|
|
109
|
+
|
|
110
|
+
1. `DCCCPY_DCCCCORE` environment variable.
|
|
111
|
+
2. A vendored binary inside the installed `dcccpy` wheel.
|
|
112
|
+
3. A binary from `dcccpy-linux-runtime`, installed by `dcccpy[linux-runtime]`.
|
|
113
|
+
4. The local dcccpy cache populated by automatic download.
|
|
114
|
+
5. `DCCCcore` on `PATH`.
|
|
115
|
+
6. Automatic download from GitHub releases, unless disabled.
|
|
116
|
+
|
|
117
|
+
Useful environment variables:
|
|
118
|
+
|
|
119
|
+
- `DCCCPY_DCCCCORE`: exact path to a `DCCCcore` executable.
|
|
120
|
+
- `DCCCPY_AUTO_DOWNLOAD=0`: disable first-run automatic download.
|
|
121
|
+
- `DCCCPY_CACHE_DIR`: override the runtime cache directory.
|
|
122
|
+
- `DCCCPY_RELEASE_REPO`: override the GitHub release repository.
|
|
123
|
+
- `DCCCPY_DCCCCORE_URL`: override the release asset URL.
|
|
124
|
+
|
|
125
|
+
## Runtime Packaging
|
|
126
|
+
|
|
127
|
+
Release wheels should vendor the matching `DCCCcore` runtime tree before build:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
python scripts/vendor_dccccore.py --version 4.2.3 --release-platform ubuntu-latest-x64
|
|
131
|
+
python -m build --wheel
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The source tree intentionally does not commit the vendored runtime because it
|
|
135
|
+
contains large ONNX model and NIfTI runtime assets.
|
|
136
|
+
|
|
137
|
+
The preferred distribution layout is:
|
|
138
|
+
|
|
139
|
+
- `dcccpy`: slim Python package with nibabel; downloads runtime on first use.
|
|
140
|
+
- `dcccpy-linux-runtime`: optional Linux runtime wheel.
|
|
141
|
+
- `dcccpy[linux-runtime]`: installs both packages.
|
|
142
|
+
|
|
143
|
+
## Packaging note
|
|
144
|
+
|
|
145
|
+
The Linux `DCCCcore-4.2.3-ubuntu-latest-x64.zip` release asset contains the
|
|
146
|
+
native executable, `libtbb`, ONNX registration/decoupling models, configuration
|
|
147
|
+
files, and NIfTI template/mask assets. That full runtime is the correct unit to
|
|
148
|
+
vendor for a wheel that works immediately after `pip install dcccpy`.
|
|
149
|
+
|
|
150
|
+
The full Linux runtime wheel is about 167 MB for version 4.2.3, so a public
|
|
151
|
+
PyPI upload may require a file size limit increase unless a future release
|
|
152
|
+
splits a smaller runtime profile from the full `DCCCcore` package.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
dcccpy/__init__.py,sha256=4idApts9ezIKuaQLtp4t0cbQ7XSAjPgByAKur38M5Lg,769
|
|
2
|
+
dcccpy/cli.py,sha256=sRTu6kOA79KzGYYXKvqWUwQb0RxZnV3B0UkbV4jFoCE,582
|
|
3
|
+
dcccpy/core.py,sha256=6Ic3SQ8EEVJDuNa_LLUGyhdAEF4QEqgQBitjLLfquRU,11013
|
|
4
|
+
dcccpy/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
5
|
+
dcccpy/runtime.py,sha256=BxiVkq-36pVR6Ze-wLtqUOW9G7GeDeA4tHf1v60z7fM,7568
|
|
6
|
+
dcccpy-0.1.2.dist-info/METADATA,sha256=xmZ0ds64GInXcL8acEqaz1I7EbuL7_WT36N7XzlaDZo,4933
|
|
7
|
+
dcccpy-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
dcccpy-0.1.2.dist-info/entry_points.txt,sha256=oPy0K-Lgnn7bd_IupKYTRe3Z2K53gJI4Osx7cA92hks,43
|
|
9
|
+
dcccpy-0.1.2.dist-info/top_level.txt,sha256=KeC_Zh9c6znBR8V5IHmeAglErsBEbmfTYSgCxgJkp1U,7
|
|
10
|
+
dcccpy-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dcccpy
|