xnatbidscli 2.0.0__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.
- xnatbidscli/__init__.py +6 -0
- xnatbidscli/archive.py +67 -0
- xnatbidscli/assets/mriconvert_qc.json +82 -0
- xnatbidscli/assets/physioconvert_qc.json +33 -0
- xnatbidscli/bidsmap.py +820 -0
- xnatbidscli/cli.py +525 -0
- xnatbidscli/cubids.py +149 -0
- xnatbidscli/download.py +1009 -0
- xnatbidscli/login.py +142 -0
- xnatbidscli/mriconfig.py +560 -0
- xnatbidscli/mriconvert.py +890 -0
- xnatbidscli/physioconvert.py +845 -0
- xnatbidscli/query.py +684 -0
- xnatbidscli/sysinfo.py +12 -0
- xnatbidscli-2.0.0.dist-info/METADATA +65 -0
- xnatbidscli-2.0.0.dist-info/RECORD +19 -0
- xnatbidscli-2.0.0.dist-info/WHEEL +4 -0
- xnatbidscli-2.0.0.dist-info/entry_points.txt +2 -0
- xnatbidscli-2.0.0.dist-info/licenses/LICENSE +21 -0
xnatbidscli/login.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import configparser
|
|
3
|
+
import getpass
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pyxnat import Interface
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR = Path.home() / ".xnatbidscli"
|
|
11
|
+
CONFIG_PATH = CONFIG_DIR / "credentials.cfg"
|
|
12
|
+
|
|
13
|
+
# Pre-rename location (the package was named "xnatcli" before the 1.x rename
|
|
14
|
+
# to "xnatbidscli"); migrated automatically the first time credentials are read.
|
|
15
|
+
_LEGACY_CONFIG_DIR = Path.home() / ".xnatcli"
|
|
16
|
+
_LEGACY_CONFIG_PATH = _LEGACY_CONFIG_DIR / "credentials.cfg"
|
|
17
|
+
_LEGACY_SECTION = "xnatcli"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _prompt(label: str) -> str:
|
|
21
|
+
value = input(f"{label}: ").strip()
|
|
22
|
+
if not value:
|
|
23
|
+
sys.exit(f"Error: {label.lower()} is required.")
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _verify(server: str, username: str, password: str) -> None:
|
|
28
|
+
interface = None
|
|
29
|
+
try:
|
|
30
|
+
interface = Interface(server=server, user=username, password=password)
|
|
31
|
+
# Force a real authenticated request so bad creds/URLs fail here.
|
|
32
|
+
interface.select.projects().get()
|
|
33
|
+
except Exception as e:
|
|
34
|
+
msg = str(e)
|
|
35
|
+
lowered = msg.lower()
|
|
36
|
+
if "401" in lowered or "unauthorized" in lowered or "authentication" in lowered:
|
|
37
|
+
sys.exit(
|
|
38
|
+
f"Error: authentication failed for user '{username}' on {server}. "
|
|
39
|
+
"Check the username and password and try again."
|
|
40
|
+
)
|
|
41
|
+
if (
|
|
42
|
+
"connection" in lowered
|
|
43
|
+
or "timeout" in lowered
|
|
44
|
+
or "not found" in lowered
|
|
45
|
+
or "name or service" in lowered
|
|
46
|
+
or "resolve" in lowered
|
|
47
|
+
):
|
|
48
|
+
sys.exit(
|
|
49
|
+
f"Error: could not reach {server}. Check the URL and your network connection."
|
|
50
|
+
)
|
|
51
|
+
sys.exit(f"Error: could not verify credentials on {server}: {msg}")
|
|
52
|
+
finally:
|
|
53
|
+
if interface is not None:
|
|
54
|
+
try:
|
|
55
|
+
interface.disconnect()
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _migrate_legacy_credentials() -> None:
|
|
61
|
+
"""Move a pre-rename ``~/.xnatcli/credentials.cfg`` to the new location.
|
|
62
|
+
|
|
63
|
+
No-op if the new config already exists, the legacy file doesn't, or the
|
|
64
|
+
legacy file is malformed. Reuses ``_write_credentials`` so the migrated
|
|
65
|
+
file gets the same ``0o600`` permissions as a freshly written one.
|
|
66
|
+
"""
|
|
67
|
+
if CONFIG_PATH.exists() or not _LEGACY_CONFIG_PATH.exists():
|
|
68
|
+
return
|
|
69
|
+
legacy = configparser.ConfigParser()
|
|
70
|
+
legacy.read(_LEGACY_CONFIG_PATH)
|
|
71
|
+
if _LEGACY_SECTION not in legacy:
|
|
72
|
+
return
|
|
73
|
+
section = legacy[_LEGACY_SECTION]
|
|
74
|
+
server, username, password = (
|
|
75
|
+
section.get("server"),
|
|
76
|
+
section.get("username"),
|
|
77
|
+
section.get("password"),
|
|
78
|
+
)
|
|
79
|
+
if not (server and username and password):
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
_write_credentials(server, username, password)
|
|
83
|
+
_LEGACY_CONFIG_PATH.unlink()
|
|
84
|
+
try:
|
|
85
|
+
_LEGACY_CONFIG_DIR.rmdir()
|
|
86
|
+
except OSError:
|
|
87
|
+
pass # directory has other files; leave it
|
|
88
|
+
print(f"Migrated credentials from {_LEGACY_CONFIG_PATH} to {CONFIG_PATH}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_credentials() -> tuple[str, str, str]:
|
|
92
|
+
_migrate_legacy_credentials()
|
|
93
|
+
if not CONFIG_PATH.exists():
|
|
94
|
+
sys.exit(
|
|
95
|
+
f"Error: no credentials found at {CONFIG_PATH}. "
|
|
96
|
+
"Run 'xnatbidscli login' first."
|
|
97
|
+
)
|
|
98
|
+
config = configparser.ConfigParser()
|
|
99
|
+
config.read(CONFIG_PATH)
|
|
100
|
+
if "xnatbidscli" not in config:
|
|
101
|
+
sys.exit(
|
|
102
|
+
f"Error: {CONFIG_PATH} is missing the [xnatbidscli] section. "
|
|
103
|
+
"Run 'xnatbidscli login' again."
|
|
104
|
+
)
|
|
105
|
+
section = config["xnatbidscli"]
|
|
106
|
+
for key in ("server", "username", "password"):
|
|
107
|
+
if not section.get(key):
|
|
108
|
+
sys.exit(
|
|
109
|
+
f"Error: {CONFIG_PATH} is missing '{key}'. "
|
|
110
|
+
"Run 'xnatbidscli login' again."
|
|
111
|
+
)
|
|
112
|
+
return section["server"], section["username"], section["password"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _write_credentials(server: str, username: str, password: str) -> None:
|
|
116
|
+
config = configparser.ConfigParser()
|
|
117
|
+
config["xnatbidscli"] = {
|
|
118
|
+
"server": server,
|
|
119
|
+
"username": username,
|
|
120
|
+
"password": password,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
fd = os.open(str(CONFIG_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
125
|
+
with os.fdopen(fd, "w") as f:
|
|
126
|
+
config.write(f)
|
|
127
|
+
# os.open's mode is masked by umask; force 0o600 explicitly.
|
|
128
|
+
os.chmod(CONFIG_PATH, 0o600)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def login_cmd(args: argparse.Namespace) -> int:
|
|
132
|
+
server = _prompt("XNAT server URL").rstrip("/")
|
|
133
|
+
username = _prompt("Username")
|
|
134
|
+
password = getpass.getpass("Password: ")
|
|
135
|
+
if not password:
|
|
136
|
+
sys.exit("Error: password is required.")
|
|
137
|
+
|
|
138
|
+
_verify(server, username, password)
|
|
139
|
+
_write_credentials(server, username, password)
|
|
140
|
+
|
|
141
|
+
print(f"Credentials verified and saved to {CONFIG_PATH}")
|
|
142
|
+
return 0
|
xnatbidscli/mriconfig.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import csv
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .sysinfo import get_system_username
|
|
15
|
+
|
|
16
|
+
STATUS_COMPLETE = "COMPLETE"
|
|
17
|
+
STATUS_FAILURE = "FAILURE"
|
|
18
|
+
|
|
19
|
+
_NON_ALNUM = re.compile(r"[^A-Za-z0-9]")
|
|
20
|
+
_DIRECTION_CODES = ("AP", "PA", "RL", "LR", "SI", "IS")
|
|
21
|
+
_DIRECTION_RE = re.compile(
|
|
22
|
+
rf"(?<![A-Za-z])({'|'.join(_DIRECTION_CODES)})(?![A-Za-z])"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_print_lock = threading.Lock()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _safe_print(msg: str) -> None:
|
|
29
|
+
with _print_lock:
|
|
30
|
+
print(msg)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _logging_now() -> str:
|
|
34
|
+
now = datetime.now()
|
|
35
|
+
return f"{now.strftime('%Y-%m-%d %H:%M:%S')},{now.microsecond // 1000:03d}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _LogWriter:
|
|
39
|
+
def __init__(self, path: Path | None):
|
|
40
|
+
self._path = path
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
self._user = get_system_username()
|
|
43
|
+
if path is not None:
|
|
44
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
with path.open("w", newline="") as f:
|
|
46
|
+
csv.writer(f).writerow(
|
|
47
|
+
[
|
|
48
|
+
"DATESTAMP",
|
|
49
|
+
"USER",
|
|
50
|
+
"PROJECT",
|
|
51
|
+
"SUBJECT",
|
|
52
|
+
"EXPERIMENT",
|
|
53
|
+
"STATUS",
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def write(
|
|
58
|
+
self,
|
|
59
|
+
datestamp: str,
|
|
60
|
+
project: str,
|
|
61
|
+
subject: str,
|
|
62
|
+
experiment: str,
|
|
63
|
+
status: str,
|
|
64
|
+
) -> None:
|
|
65
|
+
if self._path is None:
|
|
66
|
+
return
|
|
67
|
+
with self._lock, self._path.open("a", newline="") as f:
|
|
68
|
+
csv.writer(f).writerow(
|
|
69
|
+
[datestamp, self._user, project, subject, experiment, status]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _detect_direction(identity: str) -> str | None:
|
|
74
|
+
"""Last letter-bordered direction code in identity, or None."""
|
|
75
|
+
matches = _DIRECTION_RE.findall(identity)
|
|
76
|
+
return matches[-1] if matches else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _mask_direction(identity: str, code: str) -> str:
|
|
80
|
+
"""Drop all letter-bordered occurrences of code from identity."""
|
|
81
|
+
pattern = re.compile(rf"(?<![A-Za-z]){re.escape(code)}(?![A-Za-z])")
|
|
82
|
+
return pattern.sub("", identity)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _require_tool(name: str) -> str:
|
|
86
|
+
path = shutil.which(name)
|
|
87
|
+
if path is None:
|
|
88
|
+
sys.exit(
|
|
89
|
+
f"Error: required tool '{name}' was not found on PATH. "
|
|
90
|
+
"Install it and try again."
|
|
91
|
+
)
|
|
92
|
+
return path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_bids_guess_suffix(suffix_str: str) -> tuple[tuple[str, ...], str]:
|
|
96
|
+
"""Split BidsGuess[1] like '_task-rest_bold' into (entities, suffix).
|
|
97
|
+
|
|
98
|
+
Returns (("task-rest",), "bold"). For '_T1w' returns ((), "T1w").
|
|
99
|
+
"""
|
|
100
|
+
parts = suffix_str.lstrip("_").split("_")
|
|
101
|
+
return tuple(parts[:-1]), parts[-1]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _common_prefix(strings: list[str]) -> str:
|
|
105
|
+
if not strings:
|
|
106
|
+
return ""
|
|
107
|
+
s_min = min(strings)
|
|
108
|
+
s_max = max(strings)
|
|
109
|
+
i = 0
|
|
110
|
+
while i < len(s_min) and i < len(s_max) and s_min[i] == s_max[i]:
|
|
111
|
+
i += 1
|
|
112
|
+
return s_min[:i]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _common_suffix(strings: list[str]) -> str:
|
|
116
|
+
return _common_prefix([s[::-1] for s in strings])[::-1]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _disambiguation_labels(values: list[str]) -> list[str]:
|
|
120
|
+
"""One BIDS-safe acq label per value; all guaranteed unique and non-empty.
|
|
121
|
+
|
|
122
|
+
Tries minimal-diff (strip longest common prefix and suffix, sanitize to
|
|
123
|
+
[A-Za-z0-9]). Falls back to fully sanitized values if any minimal label is
|
|
124
|
+
empty or duplicates another. Final safety net appends an index when even
|
|
125
|
+
the fallback is empty or non-unique.
|
|
126
|
+
"""
|
|
127
|
+
prefix = _common_prefix(values)
|
|
128
|
+
suffix = _common_suffix(values)
|
|
129
|
+
|
|
130
|
+
minimal: list[str] = []
|
|
131
|
+
for v in values:
|
|
132
|
+
if len(prefix) + len(suffix) <= len(v):
|
|
133
|
+
core = v[len(prefix): len(v) - len(suffix)] if suffix else v[len(prefix):]
|
|
134
|
+
else:
|
|
135
|
+
core = ""
|
|
136
|
+
minimal.append(_NON_ALNUM.sub("", core))
|
|
137
|
+
if all(minimal) and len(set(minimal)) == len(minimal):
|
|
138
|
+
return minimal
|
|
139
|
+
|
|
140
|
+
full = [_NON_ALNUM.sub("", v) for v in values]
|
|
141
|
+
if all(full) and len(set(full)) == len(full):
|
|
142
|
+
return full
|
|
143
|
+
|
|
144
|
+
seen: dict[str, int] = {}
|
|
145
|
+
out: list[str] = []
|
|
146
|
+
for f in full:
|
|
147
|
+
base = f or "acq"
|
|
148
|
+
seen[base] = seen.get(base, 0) + 1
|
|
149
|
+
out.append(f"{base}{seen[base]}")
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _identity_for_sidecar(
|
|
154
|
+
jf_name: str, data: dict
|
|
155
|
+
) -> tuple[str, dict[str, str]]:
|
|
156
|
+
"""Return (identity_value, criteria_dict) for one helper sidecar.
|
|
157
|
+
|
|
158
|
+
identity_value drives the acq-label LCP/LCS comparison. criteria_dict is
|
|
159
|
+
written verbatim into the dcm2bids description's `criteria` field.
|
|
160
|
+
Priority: SeriesDescription > ProtocolName > SidecarFilename.
|
|
161
|
+
"""
|
|
162
|
+
sd = data.get("SeriesDescription")
|
|
163
|
+
pn = data.get("ProtocolName")
|
|
164
|
+
sd = sd.strip() if isinstance(sd, str) else ""
|
|
165
|
+
pn = pn.strip() if isinstance(pn, str) else ""
|
|
166
|
+
if sd:
|
|
167
|
+
return sd, {"SeriesDescription": sd}
|
|
168
|
+
if pn:
|
|
169
|
+
return pn, {"ProtocolName": pn}
|
|
170
|
+
return Path(jf_name).stem, {"SidecarFilename": jf_name}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _draft_config(target: Path) -> None:
|
|
174
|
+
helper_root = target / "tmp_dcm2bids" / "helper"
|
|
175
|
+
|
|
176
|
+
if not helper_root.is_dir():
|
|
177
|
+
print(f"Helper output directory not found: {helper_root}; skipping config draft.")
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
# With dcm2bids_helper -n <EXPERIMENT>, sidecars live at
|
|
181
|
+
# <helper_root>/<EXPERIMENT>/*.json. Aggregate across every nested
|
|
182
|
+
# experiment subdirectory so the drafted config covers the whole project.
|
|
183
|
+
json_files = sorted(helper_root.rglob("*.json"))
|
|
184
|
+
if not json_files:
|
|
185
|
+
print(f"No JSON sidecars found under {helper_root}; no config drafted.")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
# Outer group: (datatype, entities, suffix). Inner sub-group: unique
|
|
189
|
+
# identity (criteria field + value) within that group, mapped to the raw
|
|
190
|
+
# identity string used for acq-label disambiguation.
|
|
191
|
+
groups: dict[
|
|
192
|
+
tuple[str, tuple[str, ...], str],
|
|
193
|
+
dict[tuple[str, str], str],
|
|
194
|
+
] = {}
|
|
195
|
+
|
|
196
|
+
for jf in json_files:
|
|
197
|
+
try:
|
|
198
|
+
data = json.loads(jf.read_text())
|
|
199
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
200
|
+
print(f"Warning: skipping {jf.name}: cannot read JSON ({e}).")
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
guess = data.get("BidsGuess")
|
|
204
|
+
if (
|
|
205
|
+
not guess
|
|
206
|
+
or not isinstance(guess, list)
|
|
207
|
+
or len(guess) != 2
|
|
208
|
+
or not guess[0]
|
|
209
|
+
or not guess[1]
|
|
210
|
+
):
|
|
211
|
+
print(
|
|
212
|
+
f"Warning: skipping {jf.name}: missing or empty BidsGuess."
|
|
213
|
+
)
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
datatype, suffix_str = guess[0], guess[1]
|
|
217
|
+
entities, suffix = _parse_bids_guess_suffix(suffix_str)
|
|
218
|
+
# dcm2bids assigns run/echo numbering automatically. acq- from
|
|
219
|
+
# BidsGuess is a protocol-name shorthand (e.g., "acq-epfid2p3") that
|
|
220
|
+
# collides with the SeriesDescription-based acq label this draft
|
|
221
|
+
# adds for disambiguation; drop it here so we never emit two acq-.
|
|
222
|
+
entities = tuple(
|
|
223
|
+
e for e in entities
|
|
224
|
+
if not (
|
|
225
|
+
e.startswith("run-")
|
|
226
|
+
or e.startswith("echo-")
|
|
227
|
+
or e.startswith("acq-")
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
if not suffix:
|
|
231
|
+
print(
|
|
232
|
+
f"Warning: skipping {jf.name}: cannot parse suffix from "
|
|
233
|
+
f"BidsGuess {guess!r}."
|
|
234
|
+
)
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
identity, criteria = _identity_for_sidecar(jf.name, data)
|
|
238
|
+
ckey = next(iter(criteria.items()))
|
|
239
|
+
groups.setdefault((datatype, entities, suffix), {}).setdefault(
|
|
240
|
+
ckey, identity
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if not groups:
|
|
244
|
+
print("No usable BidsGuess entries; no config drafted.")
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
descriptions: list[dict] = []
|
|
248
|
+
for (datatype, entities, suffix), sub in groups.items():
|
|
249
|
+
items = list(sub.items()) # [((field, value), identity), ...]
|
|
250
|
+
if len(items) == 1:
|
|
251
|
+
(field, value), _identity = items[0]
|
|
252
|
+
desc: dict = {"datatype": datatype, "suffix": suffix}
|
|
253
|
+
if entities:
|
|
254
|
+
desc["custom_entities"] = list(entities)
|
|
255
|
+
desc["criteria"] = {field: value}
|
|
256
|
+
descriptions.append(desc)
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
# Multi-identity slot. Try to refine via direction codes.
|
|
260
|
+
guess_dir = next(
|
|
261
|
+
(e[len("dir-"):] for e in entities if e.startswith("dir-")),
|
|
262
|
+
None,
|
|
263
|
+
)
|
|
264
|
+
slot = (
|
|
265
|
+
f"{datatype}/"
|
|
266
|
+
f"{('_'.join(entities) + '_') if entities else ''}{suffix}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if guess_dir is not None:
|
|
270
|
+
# BidsGuess already pinned a direction. Don't promote; flag
|
|
271
|
+
# any identity whose detected direction disagrees with it.
|
|
272
|
+
adjusted: list[tuple[tuple[str, str], str]] = []
|
|
273
|
+
for ckey, identity in items:
|
|
274
|
+
detected = _detect_direction(identity)
|
|
275
|
+
if detected and detected != guess_dir:
|
|
276
|
+
print(
|
|
277
|
+
f"WARNING: identity {identity!r} contains direction "
|
|
278
|
+
f"code {detected!r} but BidsGuess for slot {slot} "
|
|
279
|
+
f"says dir-{guess_dir}. Leaving identity unchanged."
|
|
280
|
+
)
|
|
281
|
+
adjusted.append((ckey, identity))
|
|
282
|
+
elif detected == guess_dir:
|
|
283
|
+
adjusted.append((ckey, _mask_direction(identity, detected)))
|
|
284
|
+
else:
|
|
285
|
+
adjusted.append((ckey, identity))
|
|
286
|
+
labels = _disambiguation_labels([i for _, i in adjusted])
|
|
287
|
+
for ((field, value), _ident), label in zip(adjusted, labels):
|
|
288
|
+
descriptions.append({
|
|
289
|
+
"datatype": datatype,
|
|
290
|
+
"suffix": suffix,
|
|
291
|
+
"custom_entities": list(entities) + [f"acq-{label}"],
|
|
292
|
+
"criteria": {field: value},
|
|
293
|
+
})
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
# No dir in BidsGuess. Bucket identities by detected direction code.
|
|
297
|
+
by_dir: dict[str | None, list[tuple[tuple[str, str], str]]] = {}
|
|
298
|
+
for ckey, identity in items:
|
|
299
|
+
detected = _detect_direction(identity)
|
|
300
|
+
by_dir.setdefault(detected, []).append((ckey, identity))
|
|
301
|
+
|
|
302
|
+
for code, members in by_dir.items():
|
|
303
|
+
if code is not None:
|
|
304
|
+
masked = [_mask_direction(i, code) for _, i in members]
|
|
305
|
+
bucket_entities = list(entities) + [f"dir-{code}"]
|
|
306
|
+
else:
|
|
307
|
+
masked = [i for _, i in members]
|
|
308
|
+
bucket_entities = list(entities)
|
|
309
|
+
|
|
310
|
+
if len(members) == 1:
|
|
311
|
+
(field, value), _identity = members[0]
|
|
312
|
+
desc = {"datatype": datatype, "suffix": suffix}
|
|
313
|
+
if bucket_entities:
|
|
314
|
+
desc["custom_entities"] = bucket_entities
|
|
315
|
+
desc["criteria"] = {field: value}
|
|
316
|
+
descriptions.append(desc)
|
|
317
|
+
else:
|
|
318
|
+
labels = _disambiguation_labels(masked)
|
|
319
|
+
# Place acq- before dir- to follow BIDS canonical ordering.
|
|
320
|
+
base_entities = list(entities)
|
|
321
|
+
tail = [f"dir-{code}"] if code is not None else []
|
|
322
|
+
for ((field, value), _ident), label in zip(members, labels):
|
|
323
|
+
descriptions.append({
|
|
324
|
+
"datatype": datatype,
|
|
325
|
+
"suffix": suffix,
|
|
326
|
+
"custom_entities": base_entities
|
|
327
|
+
+ [f"acq-{label}"]
|
|
328
|
+
+ tail,
|
|
329
|
+
"criteria": {field: value},
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
333
|
+
config_path = target / f"dcm2bids_config_{ts}.json"
|
|
334
|
+
|
|
335
|
+
config_path.write_text(
|
|
336
|
+
json.dumps({"descriptions": descriptions}, indent=2) + "\n"
|
|
337
|
+
)
|
|
338
|
+
print(
|
|
339
|
+
f"Drafted dcm2bids config at {config_path} "
|
|
340
|
+
f"({len(descriptions)} description(s))."
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _draft_blank_config(target: Path) -> None:
|
|
345
|
+
"""Draft a bare-bones dcm2bids config with one entry per unique identity.
|
|
346
|
+
|
|
347
|
+
Each description has blank datatype/suffix/custom_entities and a single
|
|
348
|
+
criteria field (SeriesDescription, falling back to ProtocolName or
|
|
349
|
+
SidecarFilename per `_identity_for_sidecar`). Intended as a manually
|
|
350
|
+
editable starting point rather than the BidsGuess-derived draft from
|
|
351
|
+
`_draft_config`.
|
|
352
|
+
"""
|
|
353
|
+
helper_root = target / "tmp_dcm2bids" / "helper"
|
|
354
|
+
|
|
355
|
+
if not helper_root.is_dir():
|
|
356
|
+
print(f"Helper output directory not found: {helper_root}; skipping blank config draft.")
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
json_files = sorted(helper_root.rglob("*.json"))
|
|
360
|
+
if not json_files:
|
|
361
|
+
print(f"No JSON sidecars found under {helper_root}; no blank config drafted.")
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
seen: dict[tuple[str, str], None] = {}
|
|
365
|
+
for jf in json_files:
|
|
366
|
+
try:
|
|
367
|
+
data = json.loads(jf.read_text())
|
|
368
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
369
|
+
print(f"Warning: skipping {jf.name}: cannot read JSON ({e}).")
|
|
370
|
+
continue
|
|
371
|
+
_identity, criteria = _identity_for_sidecar(jf.name, data)
|
|
372
|
+
ckey = next(iter(criteria.items()))
|
|
373
|
+
seen.setdefault(ckey, None)
|
|
374
|
+
|
|
375
|
+
descriptions = [
|
|
376
|
+
{
|
|
377
|
+
"datatype": "",
|
|
378
|
+
"suffix": "",
|
|
379
|
+
"custom_entities": [],
|
|
380
|
+
"criteria": {field: value},
|
|
381
|
+
}
|
|
382
|
+
for field, value in seen
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
386
|
+
config_path = target / f"dcm2bids_config_blank_{ts}.json"
|
|
387
|
+
config_path.write_text(
|
|
388
|
+
json.dumps({"descriptions": descriptions}, indent=2) + "\n"
|
|
389
|
+
)
|
|
390
|
+
print(
|
|
391
|
+
f"Drafted blank dcm2bids config at {config_path} "
|
|
392
|
+
f"({len(descriptions)} description(s))."
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _discover_experiments(
|
|
397
|
+
input_root: Path, args: argparse.Namespace
|
|
398
|
+
) -> list[tuple[str, str, str]]:
|
|
399
|
+
if args.triplet is not None:
|
|
400
|
+
p, s, e = args.triplet
|
|
401
|
+
exp_dir = input_root / p / s / e
|
|
402
|
+
if not exp_dir.is_dir():
|
|
403
|
+
sys.exit(f"Error: experiment directory not found: {exp_dir}")
|
|
404
|
+
return [(p, s, e)]
|
|
405
|
+
|
|
406
|
+
if args.subject is not None:
|
|
407
|
+
project, subject = args.subject
|
|
408
|
+
subject_dir = input_root / project / subject
|
|
409
|
+
if not subject_dir.is_dir():
|
|
410
|
+
sys.exit(f"Error: subject directory not found: {subject_dir}")
|
|
411
|
+
exps = [
|
|
412
|
+
(project, subject, exp.name)
|
|
413
|
+
for exp in sorted(subject_dir.iterdir())
|
|
414
|
+
if exp.is_dir()
|
|
415
|
+
]
|
|
416
|
+
if not exps:
|
|
417
|
+
sys.exit(f"Error: no experiments found under {subject_dir}")
|
|
418
|
+
return exps
|
|
419
|
+
|
|
420
|
+
project = args.project
|
|
421
|
+
project_dir = input_root / project
|
|
422
|
+
if not project_dir.is_dir():
|
|
423
|
+
sys.exit(f"Error: project directory not found: {project_dir}")
|
|
424
|
+
exps: list[tuple[str, str, str]] = []
|
|
425
|
+
for subject_dir in sorted(project_dir.iterdir()):
|
|
426
|
+
if not subject_dir.is_dir():
|
|
427
|
+
continue
|
|
428
|
+
for exp_dir in sorted(subject_dir.iterdir()):
|
|
429
|
+
if exp_dir.is_dir():
|
|
430
|
+
exps.append((project, subject_dir.name, exp_dir.name))
|
|
431
|
+
if not exps:
|
|
432
|
+
sys.exit(f"Error: no experiments found under {project_dir}")
|
|
433
|
+
return exps
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _delete_niftis(target: Path, experiment: str) -> int:
|
|
437
|
+
"""Remove *.nii.gz from this experiment's helper subdir; return count."""
|
|
438
|
+
helper_subdir = target / "tmp_dcm2bids" / "helper" / experiment
|
|
439
|
+
if not helper_subdir.is_dir():
|
|
440
|
+
return 0
|
|
441
|
+
count = 0
|
|
442
|
+
for f in helper_subdir.glob("*.nii.gz"):
|
|
443
|
+
try:
|
|
444
|
+
f.unlink()
|
|
445
|
+
count += 1
|
|
446
|
+
except OSError:
|
|
447
|
+
pass
|
|
448
|
+
return count
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _run_helper(
|
|
452
|
+
input_root: Path,
|
|
453
|
+
project: str,
|
|
454
|
+
subject: str,
|
|
455
|
+
experiment: str,
|
|
456
|
+
target: Path,
|
|
457
|
+
helper_path: str,
|
|
458
|
+
) -> tuple[str, str | None]:
|
|
459
|
+
exp_dir = input_root / project / subject / experiment
|
|
460
|
+
scans_dir = exp_dir / "scans"
|
|
461
|
+
if not scans_dir.is_dir():
|
|
462
|
+
return STATUS_FAILURE, f"no 'scans/' subdirectory under {exp_dir}"
|
|
463
|
+
|
|
464
|
+
cmd = [
|
|
465
|
+
helper_path,
|
|
466
|
+
"-d", str(scans_dir),
|
|
467
|
+
"-o", str(target),
|
|
468
|
+
"-n", experiment,
|
|
469
|
+
"--force",
|
|
470
|
+
]
|
|
471
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
472
|
+
if result.returncode != 0:
|
|
473
|
+
stderr_tail = (result.stderr or "").strip().splitlines()[-1:] or [""]
|
|
474
|
+
return STATUS_FAILURE, (
|
|
475
|
+
f"dcm2bids_helper exited with code {result.returncode}"
|
|
476
|
+
+ (f" — {stderr_tail[0]}" if stderr_tail[0] else "")
|
|
477
|
+
)
|
|
478
|
+
return STATUS_COMPLETE, None
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def mriconfig_cmd(args: argparse.Namespace) -> int:
|
|
482
|
+
if args.nprep < 1:
|
|
483
|
+
sys.exit("Error: -n/--nprep must be >= 1.")
|
|
484
|
+
|
|
485
|
+
input_root = Path(args.input).resolve()
|
|
486
|
+
if not input_root.is_dir():
|
|
487
|
+
sys.exit(f"Error: input directory not found: {input_root}")
|
|
488
|
+
|
|
489
|
+
experiments = _discover_experiments(input_root, args)
|
|
490
|
+
project = experiments[0][0]
|
|
491
|
+
|
|
492
|
+
output_dir = Path(args.output).resolve()
|
|
493
|
+
target = output_dir / f"PROJECT-{project}_mriconfig"
|
|
494
|
+
|
|
495
|
+
# --maps: skip running dcm2bids_helper and only (re)draft the dcm2bids config
|
|
496
|
+
# from the helper JSON sidecars already present under <target>. The helper
|
|
497
|
+
# and dcm2niix tools are unused on this path, so neither is required.
|
|
498
|
+
if args.maps:
|
|
499
|
+
if not target.is_dir():
|
|
500
|
+
sys.exit(
|
|
501
|
+
f"Error: mriconfig output directory not found: {target}; run "
|
|
502
|
+
"mriconfig without -m/--maps first."
|
|
503
|
+
)
|
|
504
|
+
_draft_config(target)
|
|
505
|
+
_draft_blank_config(target)
|
|
506
|
+
return 0
|
|
507
|
+
|
|
508
|
+
helper_path = _require_tool("dcm2bids_helper")
|
|
509
|
+
_require_tool("dcm2niix")
|
|
510
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
511
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
512
|
+
|
|
513
|
+
log_path: Path | None = None
|
|
514
|
+
if args.log:
|
|
515
|
+
while True:
|
|
516
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
517
|
+
log_path = output_dir / "log" / f"mriconfig_{ts}_log.csv"
|
|
518
|
+
if not log_path.exists():
|
|
519
|
+
break
|
|
520
|
+
time.sleep(1)
|
|
521
|
+
log_writer = _LogWriter(log_path)
|
|
522
|
+
|
|
523
|
+
counts = {STATUS_COMPLETE: 0, STATUS_FAILURE: 0}
|
|
524
|
+
|
|
525
|
+
def _one(triplet: tuple[str, str, str]) -> str:
|
|
526
|
+
p, s, e = triplet
|
|
527
|
+
start = _logging_now()
|
|
528
|
+
status, detail = _run_helper(
|
|
529
|
+
input_root, p, s, e, target, helper_path
|
|
530
|
+
)
|
|
531
|
+
deleted = _delete_niftis(target, e) if args.delete else 0
|
|
532
|
+
line = f"{p}/{s}/{e}: {status}"
|
|
533
|
+
if detail:
|
|
534
|
+
line += f" — {detail}"
|
|
535
|
+
if args.delete:
|
|
536
|
+
line += f" (removed {deleted} .nii.gz)"
|
|
537
|
+
_safe_print(line)
|
|
538
|
+
log_writer.write(start, p, s, e, status)
|
|
539
|
+
return status
|
|
540
|
+
|
|
541
|
+
if args.nprep <= 1:
|
|
542
|
+
for triplet in experiments:
|
|
543
|
+
counts[_one(triplet)] += 1
|
|
544
|
+
else:
|
|
545
|
+
with ThreadPoolExecutor(max_workers=args.nprep) as ex:
|
|
546
|
+
futures = [ex.submit(_one, t) for t in experiments]
|
|
547
|
+
for fut in as_completed(futures):
|
|
548
|
+
counts[fut.result()] += 1
|
|
549
|
+
|
|
550
|
+
total = sum(counts.values())
|
|
551
|
+
print(f"\nProcessed {total} experiment(s):")
|
|
552
|
+
for status in (STATUS_COMPLETE, STATUS_FAILURE):
|
|
553
|
+
print(f" {status}: {counts[status]}")
|
|
554
|
+
if log_path is not None:
|
|
555
|
+
print(f"Log written to {log_path}")
|
|
556
|
+
|
|
557
|
+
_draft_config(target)
|
|
558
|
+
_draft_blank_config(target)
|
|
559
|
+
|
|
560
|
+
return 0 if counts[STATUS_FAILURE] == 0 else 1
|