brkraw 0.3.11__py3-none-any.whl → 0.5.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.
- brkraw/__init__.py +9 -3
- brkraw/apps/__init__.py +12 -0
- brkraw/apps/addon/__init__.py +30 -0
- brkraw/apps/addon/core.py +35 -0
- brkraw/apps/addon/dependencies.py +402 -0
- brkraw/apps/addon/installation.py +500 -0
- brkraw/apps/addon/io.py +21 -0
- brkraw/apps/hook/__init__.py +25 -0
- brkraw/apps/hook/core.py +636 -0
- brkraw/apps/loader/__init__.py +10 -0
- brkraw/apps/loader/core.py +622 -0
- brkraw/apps/loader/formatter.py +288 -0
- brkraw/apps/loader/helper.py +797 -0
- brkraw/apps/loader/info/__init__.py +11 -0
- brkraw/apps/loader/info/scan.py +85 -0
- brkraw/apps/loader/info/scan.yaml +90 -0
- brkraw/apps/loader/info/study.py +69 -0
- brkraw/apps/loader/info/study.yaml +156 -0
- brkraw/apps/loader/info/transform.py +92 -0
- brkraw/apps/loader/types.py +220 -0
- brkraw/cli/__init__.py +5 -0
- brkraw/cli/commands/__init__.py +2 -0
- brkraw/cli/commands/addon.py +327 -0
- brkraw/cli/commands/config.py +205 -0
- brkraw/cli/commands/convert.py +903 -0
- brkraw/cli/commands/hook.py +348 -0
- brkraw/cli/commands/info.py +74 -0
- brkraw/cli/commands/init.py +214 -0
- brkraw/cli/commands/params.py +106 -0
- brkraw/cli/commands/prune.py +288 -0
- brkraw/cli/commands/session.py +371 -0
- brkraw/cli/hook_args.py +80 -0
- brkraw/cli/main.py +83 -0
- brkraw/cli/utils.py +60 -0
- brkraw/core/__init__.py +13 -0
- brkraw/core/config.py +380 -0
- brkraw/core/entrypoints.py +25 -0
- brkraw/core/formatter.py +367 -0
- brkraw/core/fs.py +495 -0
- brkraw/core/jcamp.py +600 -0
- brkraw/core/layout.py +451 -0
- brkraw/core/parameters.py +781 -0
- brkraw/core/zip.py +1121 -0
- brkraw/dataclasses/__init__.py +14 -0
- brkraw/dataclasses/node.py +139 -0
- brkraw/dataclasses/reco.py +33 -0
- brkraw/dataclasses/scan.py +61 -0
- brkraw/dataclasses/study.py +131 -0
- brkraw/default/__init__.py +3 -0
- brkraw/default/pruner_specs/deid4share.yaml +42 -0
- brkraw/default/rules/00_default.yaml +4 -0
- brkraw/default/specs/metadata_dicom.yaml +236 -0
- brkraw/default/specs/metadata_transforms.py +92 -0
- brkraw/resolver/__init__.py +7 -0
- brkraw/resolver/affine.py +539 -0
- brkraw/resolver/datatype.py +69 -0
- brkraw/resolver/fid.py +90 -0
- brkraw/resolver/helpers.py +36 -0
- brkraw/resolver/image.py +188 -0
- brkraw/resolver/nifti.py +370 -0
- brkraw/resolver/shape.py +235 -0
- brkraw/schema/__init__.py +3 -0
- brkraw/schema/context_map.yaml +62 -0
- brkraw/schema/meta.yaml +57 -0
- brkraw/schema/niftiheader.yaml +95 -0
- brkraw/schema/pruner.yaml +55 -0
- brkraw/schema/remapper.yaml +128 -0
- brkraw/schema/rules.yaml +154 -0
- brkraw/specs/__init__.py +10 -0
- brkraw/specs/hook/__init__.py +12 -0
- brkraw/specs/hook/logic.py +31 -0
- brkraw/specs/hook/validator.py +22 -0
- brkraw/specs/meta/__init__.py +5 -0
- brkraw/specs/meta/validator.py +156 -0
- brkraw/specs/pruner/__init__.py +15 -0
- brkraw/specs/pruner/logic.py +361 -0
- brkraw/specs/pruner/validator.py +119 -0
- brkraw/specs/remapper/__init__.py +27 -0
- brkraw/specs/remapper/logic.py +924 -0
- brkraw/specs/remapper/validator.py +314 -0
- brkraw/specs/rules/__init__.py +6 -0
- brkraw/specs/rules/logic.py +263 -0
- brkraw/specs/rules/validator.py +103 -0
- brkraw-0.5.0.dist-info/METADATA +81 -0
- brkraw-0.5.0.dist-info/RECORD +88 -0
- {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info}/WHEEL +1 -2
- brkraw-0.5.0.dist-info/entry_points.txt +13 -0
- brkraw/lib/__init__.py +0 -4
- brkraw/lib/backup.py +0 -641
- brkraw/lib/bids.py +0 -0
- brkraw/lib/errors.py +0 -125
- brkraw/lib/loader.py +0 -1220
- brkraw/lib/orient.py +0 -194
- brkraw/lib/parser.py +0 -48
- brkraw/lib/pvobj.py +0 -301
- brkraw/lib/reference.py +0 -245
- brkraw/lib/utils.py +0 -471
- brkraw/scripts/__init__.py +0 -0
- brkraw/scripts/brk_backup.py +0 -106
- brkraw/scripts/brkraw.py +0 -744
- brkraw/ui/__init__.py +0 -0
- brkraw/ui/config.py +0 -17
- brkraw/ui/main_win.py +0 -214
- brkraw/ui/previewer.py +0 -225
- brkraw/ui/scan_info.py +0 -72
- brkraw/ui/scan_list.py +0 -73
- brkraw/ui/subj_info.py +0 -128
- brkraw-0.3.11.dist-info/METADATA +0 -25
- brkraw-0.3.11.dist-info/RECORD +0 -28
- brkraw-0.3.11.dist-info/entry_points.txt +0 -3
- brkraw-0.3.11.dist-info/top_level.txt +0 -2
- tests/__init__.py +0 -0
- {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info/licenses}/LICENSE +0 -0
brkraw/resolver/nifti.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import (
|
|
4
|
+
TYPE_CHECKING,
|
|
5
|
+
TypedDict,
|
|
6
|
+
Optional,
|
|
7
|
+
Literal,
|
|
8
|
+
Tuple,
|
|
9
|
+
Sequence,
|
|
10
|
+
Mapping,
|
|
11
|
+
Union,
|
|
12
|
+
cast,
|
|
13
|
+
Any,
|
|
14
|
+
get_args,
|
|
15
|
+
)
|
|
16
|
+
import logging
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
import numpy as np
|
|
19
|
+
from nibabel.spatialimages import HeaderDataError
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .image import ResolvedImage
|
|
24
|
+
from nibabel.nifti1 import Nifti1Image
|
|
25
|
+
|
|
26
|
+
SLOPEMODE = Literal['header', 'dataobj', 'ignore']
|
|
27
|
+
TUNIT = Literal['sec', 'msec', 'usec', 'hz', 'ppm', 'rads']
|
|
28
|
+
XYZUNIT = Literal['unknown', 'meter', 'mm', 'micron']
|
|
29
|
+
XYZTUnit = Tuple[XYZUNIT, TUNIT]
|
|
30
|
+
DimInfo = Tuple[Optional[int], Optional[int], Optional[int]]
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("brkraw")
|
|
33
|
+
|
|
34
|
+
class Nifti1HeaderContents(TypedDict, total=False):
|
|
35
|
+
flip_x: bool
|
|
36
|
+
slice_code: int
|
|
37
|
+
slope_inter: Tuple[float, float]
|
|
38
|
+
time_step: Optional[float]
|
|
39
|
+
slice_duration: Optional[float]
|
|
40
|
+
xyzt_unit: XYZTUnit
|
|
41
|
+
qform: np.ndarray
|
|
42
|
+
sform: np.ndarray
|
|
43
|
+
qform_code: int
|
|
44
|
+
sform_code: int
|
|
45
|
+
dim_info: DimInfo
|
|
46
|
+
slice_start: int
|
|
47
|
+
slice_end: int
|
|
48
|
+
intent_code: int
|
|
49
|
+
intent_name: str
|
|
50
|
+
descrip: str
|
|
51
|
+
aux_file: str
|
|
52
|
+
cal_min: float
|
|
53
|
+
cal_max: float
|
|
54
|
+
pixdim: Sequence[float]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_XYZ_UNITS = set(get_args(XYZUNIT))
|
|
58
|
+
_T_UNITS = set(get_args(TUNIT))
|
|
59
|
+
_HEADER_FIELDS = {
|
|
60
|
+
"flip_x",
|
|
61
|
+
"slice_code",
|
|
62
|
+
"slope_inter",
|
|
63
|
+
"time_step",
|
|
64
|
+
"slice_duration",
|
|
65
|
+
"xyzt_unit",
|
|
66
|
+
"qform",
|
|
67
|
+
"sform",
|
|
68
|
+
"qform_code",
|
|
69
|
+
"sform_code",
|
|
70
|
+
"dim_info",
|
|
71
|
+
"slice_start",
|
|
72
|
+
"slice_end",
|
|
73
|
+
"intent_code",
|
|
74
|
+
"intent_name",
|
|
75
|
+
"descrip",
|
|
76
|
+
"aux_file",
|
|
77
|
+
"cal_min",
|
|
78
|
+
"cal_max",
|
|
79
|
+
"pixdim",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_header_overrides(path: Optional[Union[str, Path]]) -> Optional[Nifti1HeaderContents]:
|
|
84
|
+
if not path:
|
|
85
|
+
return None
|
|
86
|
+
header_path = Path(path).expanduser()
|
|
87
|
+
if not header_path.exists():
|
|
88
|
+
logger.error("Header file not found: %s", header_path)
|
|
89
|
+
raise ValueError("header file not found")
|
|
90
|
+
if header_path.suffix.lower() not in {".yaml", ".yml"}:
|
|
91
|
+
logger.error("Header file must be .yaml/.yml: %s", header_path)
|
|
92
|
+
raise ValueError("header file must be yaml")
|
|
93
|
+
try:
|
|
94
|
+
data = yaml.safe_load(header_path.read_text(encoding="utf-8"))
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
logger.error("Failed to read header YAML: %s", exc)
|
|
97
|
+
raise ValueError("header yaml read failed") from exc
|
|
98
|
+
if data is None:
|
|
99
|
+
return None
|
|
100
|
+
if not isinstance(data, Mapping):
|
|
101
|
+
logger.error("Header YAML must be a mapping at the top level.")
|
|
102
|
+
raise ValueError("header yaml must be mapping")
|
|
103
|
+
_validate_header_schema(data)
|
|
104
|
+
return _coerce_header_contents(data)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _load_header_schema() -> Optional[Mapping[str, Any]]:
|
|
108
|
+
schema_path = Path(__file__).resolve().parents[3] / "schema" / "niftiheader.yaml"
|
|
109
|
+
if schema_path.exists():
|
|
110
|
+
return yaml.safe_load(schema_path.read_text(encoding="utf-8"))
|
|
111
|
+
logger.debug("NIfTI header schema not found: %s", schema_path)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _validate_header_schema(data: Mapping[str, Any]) -> None:
|
|
116
|
+
schema = _load_header_schema()
|
|
117
|
+
if schema is None:
|
|
118
|
+
_validate_header_minimal(data)
|
|
119
|
+
return
|
|
120
|
+
try:
|
|
121
|
+
import jsonschema
|
|
122
|
+
except Exception:
|
|
123
|
+
_validate_header_minimal(data)
|
|
124
|
+
return
|
|
125
|
+
validator = jsonschema.Draft202012Validator(schema)
|
|
126
|
+
errors = []
|
|
127
|
+
for err in validator.iter_errors(data):
|
|
128
|
+
path = ".".join(str(p) for p in err.path)
|
|
129
|
+
prefix = f"header.{path}" if path else "header"
|
|
130
|
+
errors.append(f"{prefix}: {err.message}")
|
|
131
|
+
if errors:
|
|
132
|
+
raise ValueError("Invalid NIfTI header overrides:\n" + "\n".join(errors))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _validate_header_minimal(data: Mapping[str, Any]) -> None:
|
|
136
|
+
extra = set(data.keys()) - _HEADER_FIELDS
|
|
137
|
+
if extra:
|
|
138
|
+
raise ValueError(f"Unknown NIfTI header fields: {sorted(extra)}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _coerce_bool(value: Any, *, name: str) -> bool:
|
|
142
|
+
if isinstance(value, bool):
|
|
143
|
+
return value
|
|
144
|
+
if isinstance(value, str):
|
|
145
|
+
val = value.strip().lower()
|
|
146
|
+
if val in {"1", "true", "yes", "y", "on"}:
|
|
147
|
+
return True
|
|
148
|
+
if val in {"0", "false", "no", "n", "off"}:
|
|
149
|
+
return False
|
|
150
|
+
raise ValueError(f"Invalid {name}: {value!r}")
|
|
151
|
+
return bool(value)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _coerce_header_contents(data: Mapping[str, Any]) -> Nifti1HeaderContents:
|
|
155
|
+
header: Nifti1HeaderContents = {}
|
|
156
|
+
for key, value in data.items():
|
|
157
|
+
if value is None:
|
|
158
|
+
if key in {"time_step", "slice_duration"}:
|
|
159
|
+
header[key] = None
|
|
160
|
+
continue
|
|
161
|
+
raise ValueError(f"{key} cannot be null.")
|
|
162
|
+
if key == "flip_x":
|
|
163
|
+
header[key] = _coerce_bool(value, name=key)
|
|
164
|
+
elif key in {"slice_code", "qform_code", "sform_code", "slice_start", "slice_end", "intent_code"}:
|
|
165
|
+
header[key] = int(value)
|
|
166
|
+
elif key in {"time_step", "slice_duration", "cal_min", "cal_max"}:
|
|
167
|
+
header[key] = float(value)
|
|
168
|
+
elif key == "slope_inter":
|
|
169
|
+
if not isinstance(value, (list, tuple)) or len(value) != 2:
|
|
170
|
+
raise ValueError("slope_inter must be a 2-item list.")
|
|
171
|
+
header[key] = (float(value[0]), float(value[1]))
|
|
172
|
+
elif key == "xyzt_unit":
|
|
173
|
+
if not isinstance(value, (list, tuple)) or len(value) != 2:
|
|
174
|
+
raise ValueError("xyzt_unit must be a 2-item list.")
|
|
175
|
+
xyz, t = value
|
|
176
|
+
if str(xyz) not in _XYZ_UNITS or str(t) not in _T_UNITS:
|
|
177
|
+
raise ValueError("xyzt_unit must be one of supported XYZUNIT/TUNIT values.")
|
|
178
|
+
header[key] = cast(XYZTUnit, (cast(XYZUNIT, str(xyz)), cast(TUNIT, str(t))))
|
|
179
|
+
elif key in {"qform", "sform"}:
|
|
180
|
+
arr = np.asarray(value, dtype=float)
|
|
181
|
+
if arr.shape != (4, 4):
|
|
182
|
+
raise ValueError(f"{key} must be a 4x4 matrix.")
|
|
183
|
+
header[key] = arr
|
|
184
|
+
elif key == "dim_info":
|
|
185
|
+
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
|
186
|
+
raise ValueError("dim_info must be a 3-item list.")
|
|
187
|
+
dim0 = None if value[0] is None else int(value[0])
|
|
188
|
+
dim1 = None if value[1] is None else int(value[1])
|
|
189
|
+
dim2 = None if value[2] is None else int(value[2])
|
|
190
|
+
header[key] = (dim0, dim1, dim2)
|
|
191
|
+
elif key in {"intent_name", "descrip", "aux_file"}:
|
|
192
|
+
header[key] = str(value)
|
|
193
|
+
elif key == "pixdim":
|
|
194
|
+
if not isinstance(value, (list, tuple)) or not value:
|
|
195
|
+
raise ValueError("pixdim must be a non-empty list.")
|
|
196
|
+
header[key] = [float(v) for v in value]
|
|
197
|
+
else:
|
|
198
|
+
raise ValueError(f"Unknown NIfTI header field: {key}")
|
|
199
|
+
return header
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _coerce_scalar(value, *, name: str) -> float:
|
|
203
|
+
if isinstance(value, np.ndarray):
|
|
204
|
+
if value.size == 0:
|
|
205
|
+
return 0.0
|
|
206
|
+
if value.size > 1:
|
|
207
|
+
logger.debug("NIfTI %s array has multiple values; using first element.", name)
|
|
208
|
+
return float(value.flat[0])
|
|
209
|
+
if isinstance(value, (list, tuple)):
|
|
210
|
+
if not value:
|
|
211
|
+
return 0.0
|
|
212
|
+
if len(value) > 1:
|
|
213
|
+
logger.debug("NIfTI %s list has multiple values; using first element.", name)
|
|
214
|
+
seq = cast(Sequence[float], value)
|
|
215
|
+
return float(seq[0])
|
|
216
|
+
return float(value)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _coerce_int(value, *, name: str) -> int:
|
|
220
|
+
return int(_coerce_scalar(value, name=name))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _get_slice_code(sliceorder_scheme: Optional[str]) -> int:
|
|
224
|
+
if sliceorder_scheme is None or sliceorder_scheme == "User_defined_slice_scheme":
|
|
225
|
+
return 0
|
|
226
|
+
if sliceorder_scheme == "Sequential":
|
|
227
|
+
return 1
|
|
228
|
+
elif sliceorder_scheme == 'Reverse_sequential':
|
|
229
|
+
return 2
|
|
230
|
+
elif sliceorder_scheme == 'Interlaced':
|
|
231
|
+
return 3
|
|
232
|
+
elif sliceorder_scheme == 'Reverse_interlacesd':
|
|
233
|
+
return 4
|
|
234
|
+
elif sliceorder_scheme == 'Angiopraphy':
|
|
235
|
+
return 5
|
|
236
|
+
else:
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _set_dataobj(niiobj: "Nifti1Image", dataobj: np.ndarray) -> None:
|
|
241
|
+
"""Update the NIfTI data object, falling back to direct assignment."""
|
|
242
|
+
setter = getattr(niiobj, "set_dataobj", None)
|
|
243
|
+
if callable(setter):
|
|
244
|
+
setter(dataobj)
|
|
245
|
+
else:
|
|
246
|
+
object.__setattr__(niiobj, "_dataobj", dataobj)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def resolve(
|
|
250
|
+
image_info: "ResolvedImage",
|
|
251
|
+
flip_x: bool = False,
|
|
252
|
+
xyz_units: "XYZUNIT" = 'mm',
|
|
253
|
+
t_units: "TUNIT" = 'sec'
|
|
254
|
+
) -> Nifti1HeaderContents:
|
|
255
|
+
|
|
256
|
+
sliceorder_scheme = image_info['sliceorder_scheme']
|
|
257
|
+
num_cycles = image_info['num_cycles']
|
|
258
|
+
|
|
259
|
+
slice_code = _get_slice_code(sliceorder_scheme)
|
|
260
|
+
if slice_code == 0:
|
|
261
|
+
logger.debug(
|
|
262
|
+
"Failed to identify compatible 'slice_code'. "
|
|
263
|
+
"Please use this header information with care in case slice timing correction is needed."
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if num_cycles > 1:
|
|
267
|
+
time_step = cast(float, image_info['time_per_cycle']) / 1000.0
|
|
268
|
+
num_slices = image_info['dataobj'].shape[2]
|
|
269
|
+
slice_duration = time_step / num_slices
|
|
270
|
+
else:
|
|
271
|
+
time_step = None
|
|
272
|
+
slice_duration = None
|
|
273
|
+
slope = image_info['slope']
|
|
274
|
+
offset = image_info['offset']
|
|
275
|
+
result: Nifti1HeaderContents = {
|
|
276
|
+
'flip_x': flip_x,
|
|
277
|
+
'slice_code': slice_code,
|
|
278
|
+
'slope_inter': (slope, offset),
|
|
279
|
+
'time_step': time_step,
|
|
280
|
+
'slice_duration': slice_duration,
|
|
281
|
+
'xyzt_unit': (xyz_units, t_units)
|
|
282
|
+
}
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def update(
|
|
287
|
+
niiobj: "Nifti1Image",
|
|
288
|
+
nifti1header_contents: Nifti1HeaderContents,
|
|
289
|
+
slope_mode: SLOPEMODE = 'header',
|
|
290
|
+
):
|
|
291
|
+
qform_code = nifti1header_contents.get("qform_code")
|
|
292
|
+
sform_code = nifti1header_contents.get("sform_code")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
for c, val in nifti1header_contents.items():
|
|
296
|
+
if val is None or c in ('qform_code', 'sform_code'):
|
|
297
|
+
continue
|
|
298
|
+
if c == 'flip_x':
|
|
299
|
+
niiobj.header.default_x_flip = bool(val)
|
|
300
|
+
elif c == "slice_code":
|
|
301
|
+
if _coerce_int(val, name="slice_code") != 0:
|
|
302
|
+
niiobj.header['slice_code'] = _coerce_int(val, name="slice_code")
|
|
303
|
+
elif c == "slope_inter":
|
|
304
|
+
pair = cast(Sequence[float], val)
|
|
305
|
+
slope_val = _coerce_scalar(pair[0], name="slope")
|
|
306
|
+
inter_val = _coerce_scalar(pair[1], name="intercept")
|
|
307
|
+
if slope_mode == 'header':
|
|
308
|
+
niiobj.header.set_slope_inter(slope_val, inter_val)
|
|
309
|
+
elif slope_mode == 'dataobj':
|
|
310
|
+
dataobj = np.asarray(niiobj._dataobj)
|
|
311
|
+
_set_dataobj(niiobj, dataobj * slope_val + inter_val)
|
|
312
|
+
else:
|
|
313
|
+
pass
|
|
314
|
+
niiobj.header.set_data_dtype(np.asarray(niiobj._dataobj).dtype)
|
|
315
|
+
elif c == "time_step":
|
|
316
|
+
niiobj.header['pixdim'][4] = _coerce_scalar(val, name="time_step")
|
|
317
|
+
elif c == "slice_duration":
|
|
318
|
+
slice_dim = niiobj.header.get_dim_info()[2]
|
|
319
|
+
if slice_dim is None:
|
|
320
|
+
logger.debug("Skipping slice_duration: slice dimension not set.")
|
|
321
|
+
continue
|
|
322
|
+
try:
|
|
323
|
+
niiobj.header.set_slice_duration(_coerce_scalar(val, name="slice_duration"))
|
|
324
|
+
except HeaderDataError as exc:
|
|
325
|
+
logger.debug("Skipping slice_duration: %s", exc)
|
|
326
|
+
elif c == "xyzt_unit":
|
|
327
|
+
units = cast(Sequence[str], val)
|
|
328
|
+
niiobj.header.set_xyzt_units(*units)
|
|
329
|
+
elif c == "qform":
|
|
330
|
+
if qform_code is None:
|
|
331
|
+
niiobj.header.set_qform(val, 1)
|
|
332
|
+
else:
|
|
333
|
+
niiobj.header.set_qform(val, int(qform_code))
|
|
334
|
+
elif c == "sform":
|
|
335
|
+
if sform_code is None:
|
|
336
|
+
niiobj.header.set_sform(val, 1)
|
|
337
|
+
else:
|
|
338
|
+
niiobj.header.set_sform(val, int(sform_code))
|
|
339
|
+
elif c == "dim_info":
|
|
340
|
+
dims = cast(Sequence[Optional[int]], val)
|
|
341
|
+
niiobj.header.set_dim_info(*dims)
|
|
342
|
+
elif c == "slice_start":
|
|
343
|
+
niiobj.header['slice_start'] = _coerce_int(val, name="slice_start")
|
|
344
|
+
elif c == "slice_end":
|
|
345
|
+
niiobj.header['slice_end'] = _coerce_int(val, name="slice_end")
|
|
346
|
+
elif c == "intent_code":
|
|
347
|
+
niiobj.header['intent_code'] = _coerce_int(val, name="intent_code")
|
|
348
|
+
elif c == "intent_name":
|
|
349
|
+
niiobj.header['intent_name'] = str(val)
|
|
350
|
+
elif c == "descrip":
|
|
351
|
+
niiobj.header['descrip'] = str(val)
|
|
352
|
+
elif c == "aux_file":
|
|
353
|
+
niiobj.header['aux_file'] = str(val)
|
|
354
|
+
elif c == "cal_min":
|
|
355
|
+
niiobj.header['cal_min'] = _coerce_scalar(val, name="cal_min")
|
|
356
|
+
elif c == "cal_max":
|
|
357
|
+
niiobj.header['cal_max'] = _coerce_scalar(val, name="cal_max")
|
|
358
|
+
elif c == "pixdim":
|
|
359
|
+
if val:
|
|
360
|
+
niiobj.header['pixdim'][1:1 + len(val)] = val # pyright: ignore[reportArgumentType]
|
|
361
|
+
else:
|
|
362
|
+
raise KeyError(f"Unknown NIfTI header field: {c}")
|
|
363
|
+
return niiobj
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
__all__ = [
|
|
367
|
+
'resolve',
|
|
368
|
+
'update',
|
|
369
|
+
'load_header_overrides',
|
|
370
|
+
]
|
brkraw/resolver/shape.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resolve Paravision geometry metadata into lightweight dictionaries.
|
|
3
|
+
|
|
4
|
+
This module reads parameter files from `Scan`/`Reco` nodes and normalizes
|
|
5
|
+
image geometry (dims, FOV, voxel size), frame-group structure, slice-pack
|
|
6
|
+
layout, and cycle timing into small TypedDicts. Helpers can be called
|
|
7
|
+
independently or via `resolve`, which bundles the pieces into a single report.
|
|
8
|
+
Returns None when required metadata is missing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
from typing import TYPE_CHECKING, Optional, List, Tuple, Literal, TypedDict, Sequence, Union, cast
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from math import prod
|
|
15
|
+
import re
|
|
16
|
+
import numpy as np
|
|
17
|
+
from .helpers import get_reco, get_file, strip_comment
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ..dataclasses import Reco, Scan
|
|
20
|
+
from ..core.parameters import Parameters
|
|
21
|
+
|
|
22
|
+
FrameGroupEntry = Tuple[int, str, str, int, int]
|
|
23
|
+
FrameGroupOrder = List[FrameGroupEntry]
|
|
24
|
+
|
|
25
|
+
class ResolvedImageInfo(TypedDict):
|
|
26
|
+
dim: Optional[int]
|
|
27
|
+
dim_desc: Optional[Sequence[str]]
|
|
28
|
+
fov: Optional[List[float]]
|
|
29
|
+
shape: Optional[List[int]]
|
|
30
|
+
resol: Optional[List[float]]
|
|
31
|
+
unit: Literal["mm"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ResolvedFrameGroup(TypedDict):
|
|
35
|
+
type: Optional[Union[str, List[str]]]
|
|
36
|
+
id: List[str]
|
|
37
|
+
shape: List[int]
|
|
38
|
+
comments: List[Optional[str]]
|
|
39
|
+
dependent_vals: List[List[object]]
|
|
40
|
+
size: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ResolvedCycle(TypedDict):
|
|
44
|
+
num_cycles: int
|
|
45
|
+
time_step: float
|
|
46
|
+
unit: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ResolvedShape(TypedDict):
|
|
50
|
+
shape: List[int]
|
|
51
|
+
shape_desc: List[str]
|
|
52
|
+
num_cycle: int
|
|
53
|
+
sliceorder_scheme: Optional[str]
|
|
54
|
+
objs: ResolvedCollection
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ResolvedCollection:
|
|
59
|
+
image: Optional[ResolvedImageInfo]
|
|
60
|
+
frame_group: Optional[ResolvedFrameGroup]
|
|
61
|
+
cycle: Optional[ResolvedCycle]
|
|
62
|
+
|
|
63
|
+
def __repr__(self):
|
|
64
|
+
resolved = []
|
|
65
|
+
if self.image:
|
|
66
|
+
resolved.append('image')
|
|
67
|
+
if self.frame_group:
|
|
68
|
+
resolved.append('frame_group')
|
|
69
|
+
if self.cycle:
|
|
70
|
+
resolved.append('cycle')
|
|
71
|
+
resolved = ', '.join(resolved)
|
|
72
|
+
return f'<Resolved: {resolved}>'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_image_info(visu_pars: "Parameters") -> Optional[ResolvedImageInfo]:
|
|
76
|
+
"""Return core image geometry from visu_pars (in millimeters).
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
visu_pars: Paravision visu_pars Parameter object from Reco.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
ImageInfo with dim, fov, shape, resolution, and unit; None if missing.
|
|
83
|
+
"""
|
|
84
|
+
dim = visu_pars.get('VisuCoreDim')
|
|
85
|
+
dim_desc = visu_pars.get('VisuCoreDimDesc')
|
|
86
|
+
fov_arr = visu_pars.get('VisuCoreExtent')
|
|
87
|
+
shape_arr = visu_pars.get('VisuCoreSize')
|
|
88
|
+
resol_arr = np.divide(fov_arr, shape_arr) if (fov_arr is not None and shape_arr is not None) else None
|
|
89
|
+
return {
|
|
90
|
+
'dim': cast(Optional[int], dim),
|
|
91
|
+
'dim_desc': dim_desc.tolist() if isinstance(dim_desc, np.ndarray) else cast(Optional[Sequence[str]], dim_desc),
|
|
92
|
+
'fov': fov_arr.tolist() if isinstance(fov_arr, np.ndarray) else cast(Optional[List[float]], fov_arr),
|
|
93
|
+
'shape': shape_arr.tolist() if isinstance(shape_arr, np.ndarray) else cast(Optional[List[int]], shape_arr),
|
|
94
|
+
'resol': resol_arr.tolist() if isinstance(resol_arr, np.ndarray) else cast(Optional[List[float]], resol_arr),
|
|
95
|
+
'unit': 'mm',
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve_frame_group(visu_pars: "Parameters") -> Optional[ResolvedFrameGroup]:
|
|
100
|
+
"""Parse VisuCore frame-group description into a normalized structure.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
visu_pars: Paravision visu_pars Parameter object from Reco.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
FrameGroupInfo with ids, shapes, comments, dependent values, and size;
|
|
107
|
+
None if absent.
|
|
108
|
+
"""
|
|
109
|
+
if not visu_pars.get('VisuFGOrderDescDim'):
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
fg_order_raw = visu_pars.get('VisuFGOrderDesc')
|
|
113
|
+
if fg_order_raw is None:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def _normalize_order(raw: object) -> FrameGroupOrder:
|
|
117
|
+
if not isinstance(raw, (list, tuple)):
|
|
118
|
+
raise TypeError("VisuFGOrderDesc")
|
|
119
|
+
if raw and not isinstance(raw[0], (list, tuple)):
|
|
120
|
+
return [cast(FrameGroupEntry, raw)]
|
|
121
|
+
return [cast(FrameGroupEntry, item) for item in raw]
|
|
122
|
+
|
|
123
|
+
def _dependent_values(start: int, count: int) -> List[object]:
|
|
124
|
+
if not count:
|
|
125
|
+
return []
|
|
126
|
+
values = visu_pars['VisuGroupDepVals']
|
|
127
|
+
return [values[start + i] for i in range(count)]
|
|
128
|
+
|
|
129
|
+
fg_order = _normalize_order(fg_order_raw)
|
|
130
|
+
fg_type_raw = visu_pars.get('VisuCoreFrameType')
|
|
131
|
+
fg_type: Optional[Union[str, List[str]]]
|
|
132
|
+
if isinstance(fg_type_raw, np.ndarray):
|
|
133
|
+
fg_type = cast(List[str], fg_type_raw.tolist())
|
|
134
|
+
else:
|
|
135
|
+
fg_type = cast(Optional[Union[str, List[str]]], fg_type_raw)
|
|
136
|
+
fg_id: List[str] = [str(entry[1]) for entry in fg_order]
|
|
137
|
+
shape: List[int] = [int(entry[0]) for entry in fg_order]
|
|
138
|
+
comments: List[Optional[str]] = [strip_comment(entry[2]) for entry in fg_order]
|
|
139
|
+
dependent_vals: List[List[object]] = [_dependent_values(int(entry[3]), int(entry[4])) for entry in fg_order]
|
|
140
|
+
|
|
141
|
+
result: ResolvedFrameGroup = {
|
|
142
|
+
'type': fg_type,
|
|
143
|
+
'id': fg_id,
|
|
144
|
+
'shape': shape,
|
|
145
|
+
'comments': comments,
|
|
146
|
+
'dependent_vals': dependent_vals,
|
|
147
|
+
'size': prod(shape) if shape else 0
|
|
148
|
+
}
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def resolve_cycle(visu_pars: "Parameters", fg_info: Optional[ResolvedFrameGroup] = None) -> Optional[ResolvedCycle]:
|
|
153
|
+
"""Derive cycle count and time step from frame-group metadata.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
visu_pars: Paravision visu_pars Parameter object from Reco.
|
|
157
|
+
fg_info: Optional precomputed frame-group info; computed if omitted.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
CycleInfo with cycle count and time step (msec); None if not applicable.
|
|
161
|
+
"""
|
|
162
|
+
scan_time = visu_pars.get("VisuAcqScanTime") or 0
|
|
163
|
+
if fg_info:
|
|
164
|
+
fg_cycle = [fg_info['shape'][idx] for idx, fg in enumerate(fg_info['id']) if re.search('cycle', fg, re.IGNORECASE)]
|
|
165
|
+
num_cycles = fg_cycle[-1] if fg_cycle else 1
|
|
166
|
+
time_step = (scan_time / num_cycles) if num_cycles else 0
|
|
167
|
+
return {
|
|
168
|
+
'num_cycles': num_cycles,
|
|
169
|
+
'time_step': time_step,
|
|
170
|
+
'unit': 'msec',
|
|
171
|
+
}
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def resolve(scan: "Scan", reco_id: int = 1):
|
|
176
|
+
"""Resolve image, frame-group, cycle, and slice-pack metadata for a scan.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
scan: Scan node.
|
|
180
|
+
reco_id: Reco id to process.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Mapping with combined shape info, descriptors, counts, and a ResolvedInfo bundle;
|
|
184
|
+
None if required files are missing.
|
|
185
|
+
"""
|
|
186
|
+
reco: "Reco" = get_reco(scan, reco_id)
|
|
187
|
+
try:
|
|
188
|
+
method: "Parameters" = get_file(scan, 'method')
|
|
189
|
+
except FileNotFoundError:
|
|
190
|
+
return None
|
|
191
|
+
try:
|
|
192
|
+
visu_pars: "Parameters" = get_file(reco, 'visu_pars')
|
|
193
|
+
except FileNotFoundError:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
img_info = resolve_image_info(visu_pars)
|
|
197
|
+
fg_info = resolve_frame_group(visu_pars)
|
|
198
|
+
cycle_info = resolve_cycle(visu_pars, fg_info)
|
|
199
|
+
sliceorder_scheme = method.get("PVM_ObjOrderScheme")
|
|
200
|
+
|
|
201
|
+
shape_source = img_info['shape'] if img_info else None
|
|
202
|
+
shape = list(shape_source) if shape_source else []
|
|
203
|
+
dim_desc_source = img_info['dim_desc'] if img_info else None
|
|
204
|
+
if isinstance(dim_desc_source, str):
|
|
205
|
+
shape_desc: List[str] = [dim_desc_source]
|
|
206
|
+
elif dim_desc_source:
|
|
207
|
+
shape_desc = list(dim_desc_source)
|
|
208
|
+
else:
|
|
209
|
+
shape_desc = []
|
|
210
|
+
num_cycles = cycle_info['num_cycles'] if cycle_info else 1
|
|
211
|
+
fg_desc = [strip_comment(i).replace('FG_', '').strip().lower() for i in fg_info.get('id', [])] if fg_info else []
|
|
212
|
+
|
|
213
|
+
if img_info and img_info['dim'] == 2:
|
|
214
|
+
if not fg_info or (fg_info and 'slice' not in fg_desc):
|
|
215
|
+
shape.extend([1])
|
|
216
|
+
shape_desc.extend(['without_slice'])
|
|
217
|
+
|
|
218
|
+
if fg_info and fg_info.get('type') != None:
|
|
219
|
+
shape.extend(fg_info.get('shape', []))
|
|
220
|
+
shape_desc.extend(fg_desc)
|
|
221
|
+
|
|
222
|
+
result: ResolvedShape = {
|
|
223
|
+
'shape': shape,
|
|
224
|
+
'shape_desc': shape_desc,
|
|
225
|
+
'num_cycle': num_cycles,
|
|
226
|
+
'sliceorder_scheme': sliceorder_scheme,
|
|
227
|
+
'objs': ResolvedCollection(
|
|
228
|
+
img_info, fg_info, cycle_info
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
__all__ = [
|
|
234
|
+
'resolve'
|
|
235
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
title: "BrkRaw Context Map"
|
|
2
|
+
description: "Per-key mapping rules supplied at runtime."
|
|
3
|
+
type: object
|
|
4
|
+
properties:
|
|
5
|
+
__meta__:
|
|
6
|
+
type: object
|
|
7
|
+
description: "Optional layout metadata for output rendering."
|
|
8
|
+
additionalProperties: false
|
|
9
|
+
properties:
|
|
10
|
+
layout_entries:
|
|
11
|
+
type: array
|
|
12
|
+
description: "Layout entries for output naming."
|
|
13
|
+
items:
|
|
14
|
+
type: object
|
|
15
|
+
layout_template:
|
|
16
|
+
type: string
|
|
17
|
+
description: "Template string for layout naming."
|
|
18
|
+
slicepack_suffix:
|
|
19
|
+
type: string
|
|
20
|
+
description: "Template for slicepack suffix rendering."
|
|
21
|
+
additionalProperties:
|
|
22
|
+
oneOf:
|
|
23
|
+
- $ref: "#/$defs/Rule"
|
|
24
|
+
- type: array
|
|
25
|
+
items:
|
|
26
|
+
$ref: "#/$defs/Rule"
|
|
27
|
+
|
|
28
|
+
$defs:
|
|
29
|
+
Rule:
|
|
30
|
+
type: object
|
|
31
|
+
description: "Mapping rule for a spec output key."
|
|
32
|
+
additionalProperties: false
|
|
33
|
+
properties:
|
|
34
|
+
selector:
|
|
35
|
+
type: boolean
|
|
36
|
+
description: "If true, this key must be mapped for conversion selection."
|
|
37
|
+
target:
|
|
38
|
+
type: string
|
|
39
|
+
description: "Spec output to apply this mapping to (default: info_spec)."
|
|
40
|
+
enum: ["info_spec", "metadata_spec"]
|
|
41
|
+
type:
|
|
42
|
+
type: string
|
|
43
|
+
enum: ["mapping", "const"]
|
|
44
|
+
values:
|
|
45
|
+
type: object
|
|
46
|
+
description: "Lookup mapping table."
|
|
47
|
+
additionalProperties: true
|
|
48
|
+
value:
|
|
49
|
+
description: "Constant value for type=const."
|
|
50
|
+
default:
|
|
51
|
+
description: "Default value when no mapping matches."
|
|
52
|
+
override:
|
|
53
|
+
type: boolean
|
|
54
|
+
when:
|
|
55
|
+
type: object
|
|
56
|
+
description: "Conditions evaluated against mapped values."
|
|
57
|
+
additionalProperties: true
|
|
58
|
+
cases:
|
|
59
|
+
type: array
|
|
60
|
+
description: "Nested rule list evaluated after the parent rule matches."
|
|
61
|
+
items:
|
|
62
|
+
$ref: "#/$defs/Rule"
|