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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import re
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def strip_jcamp_string(value: Optional[str]) -> str:
|
|
9
|
+
if value is None:
|
|
10
|
+
return "Unknown"
|
|
11
|
+
text = str(value).strip()
|
|
12
|
+
if text.startswith("<") and text.endswith(">"):
|
|
13
|
+
text = text[1:-1]
|
|
14
|
+
text = re.sub(r"\^+", " ", text)
|
|
15
|
+
return " ".join(text.split())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def to_seconds(value):
|
|
19
|
+
if value is None:
|
|
20
|
+
return None
|
|
21
|
+
if isinstance(value, (list, tuple, np.ndarray)):
|
|
22
|
+
arr = np.asarray(value, dtype=float)
|
|
23
|
+
return (arr / 1000.0).tolist()
|
|
24
|
+
return float(value) / 1000.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def first_seconds(value):
|
|
28
|
+
if value is None:
|
|
29
|
+
return None
|
|
30
|
+
if isinstance(value, (list, tuple, np.ndarray)):
|
|
31
|
+
arr = np.asarray(value, dtype=float).ravel()
|
|
32
|
+
if arr.size == 0:
|
|
33
|
+
return None
|
|
34
|
+
return float(arr[0]) / 1000.0
|
|
35
|
+
return float(value) / 1000.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def freq_to_field(value=None, freq=None):
|
|
39
|
+
if freq is None:
|
|
40
|
+
freq = value
|
|
41
|
+
if freq is None:
|
|
42
|
+
return None
|
|
43
|
+
return float(freq) / 42.576
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def as_list(value):
|
|
47
|
+
if isinstance(value, np.ndarray):
|
|
48
|
+
return value.tolist()
|
|
49
|
+
if isinstance(value, (list, tuple)):
|
|
50
|
+
return list(value)
|
|
51
|
+
return [value]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pixel_spacing_from_extent(extent=None, size=None):
|
|
55
|
+
if extent is None or size is None:
|
|
56
|
+
return None
|
|
57
|
+
arr_extent = np.asarray(extent, dtype=float).ravel()
|
|
58
|
+
arr_size = np.asarray(size, dtype=float).ravel()
|
|
59
|
+
if arr_extent.size == 0 or arr_size.size == 0:
|
|
60
|
+
return None
|
|
61
|
+
if arr_extent.size != arr_size.size:
|
|
62
|
+
count = min(arr_extent.size, arr_size.size)
|
|
63
|
+
if count == 0:
|
|
64
|
+
return None
|
|
65
|
+
arr_extent = arr_extent[:count]
|
|
66
|
+
arr_size = arr_size[:count]
|
|
67
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
68
|
+
spacing = arr_extent / arr_size
|
|
69
|
+
return spacing.tolist()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def normalize_method(value: Optional[str]) -> str:
|
|
73
|
+
return strip_jcamp_string(value).upper()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def pick_value(value=None, **_):
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def volume_timing(tr=None, nr=None):
|
|
81
|
+
if tr is None or nr is None:
|
|
82
|
+
return None
|
|
83
|
+
tr_sec = first_seconds(tr)
|
|
84
|
+
if tr_sec is None:
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
count = int(np.asarray(nr).ravel()[0])
|
|
88
|
+
except Exception:
|
|
89
|
+
return None
|
|
90
|
+
if count <= 0:
|
|
91
|
+
return None
|
|
92
|
+
return (np.arange(count) * tr_sec).tolist()
|
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Optional, Union, TypedDict, Tuple, Literal, List, Any
|
|
3
|
+
from typing import cast, TYPE_CHECKING
|
|
4
|
+
from .helpers import get_file, get_reco, return_alt_val_if_none
|
|
5
|
+
import logging
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("brkraw")
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..dataclasses import Scan, Reco
|
|
12
|
+
from ..core.parameters import Parameters
|
|
13
|
+
|
|
14
|
+
SubjectType = Literal["Biped", "Quadruped", "Phantom", "Other", "OtherAnimal"]
|
|
15
|
+
SubjectPose = Literal[
|
|
16
|
+
"Head_Supine", "Head_Prone", "Head_Left", "Head_Right",
|
|
17
|
+
"Foot_Supine", "Foot_Prone", "Foot_Left", "Foot_Right",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ResolvedSlicePack(TypedDict):
|
|
22
|
+
num_slice_packs: int
|
|
23
|
+
num_slices: List[int]
|
|
24
|
+
slice_thickness: List[Union[float, int]]
|
|
25
|
+
slice_gap: List[Union[float, int]]
|
|
26
|
+
unit: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ResolvedAffine(TypedDict):
|
|
30
|
+
num_slice_packs: int
|
|
31
|
+
affines: List[np.ndarray]
|
|
32
|
+
num_slices: List[int]
|
|
33
|
+
subject_type: Optional[SubjectType]
|
|
34
|
+
subject_position: SubjectPose
|
|
35
|
+
is_unwrapped: bool
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def from_matvec(mat: np.ndarray, vec: np.ndarray) -> np.ndarray:
|
|
39
|
+
"""Create a 4x4 affine matrix from a 3x3 rotation/scale matrix and a 3-vector."""
|
|
40
|
+
if mat.shape == (3, 3) and vec.shape == (3,):
|
|
41
|
+
affine = np.eye(4)
|
|
42
|
+
affine[:3, :3] = mat
|
|
43
|
+
affine[:3, 3] = vec
|
|
44
|
+
return affine
|
|
45
|
+
else:
|
|
46
|
+
raise ValueError("Matrix must be 3x3 and vector must be 1x3")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def to_matvec(affine: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
50
|
+
"""Decompose a 4x4 affine matrix into a 3x3 matrix and a 3-vector."""
|
|
51
|
+
if affine.shape != (4, 4):
|
|
52
|
+
raise ValueError("Affine matrix must be 4x4")
|
|
53
|
+
mat = affine[:3, :3]
|
|
54
|
+
vec = affine[:3, 3]
|
|
55
|
+
return mat, vec
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def rotate_affine(
|
|
59
|
+
affine: np.ndarray,
|
|
60
|
+
rad_x: float = 0.0,
|
|
61
|
+
rad_y: float = 0.0,
|
|
62
|
+
rad_z: float = 0.0,
|
|
63
|
+
pivot: Optional[np.ndarray] = None,
|
|
64
|
+
) -> np.ndarray:
|
|
65
|
+
"""
|
|
66
|
+
Rotate a 4x4 affine around a pivot point by given radians along x, y, z axes.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
affine : (4,4) ndarray
|
|
71
|
+
Input affine matrix.
|
|
72
|
+
rad_x, rad_y, rad_z : float
|
|
73
|
+
Rotation angles in radians about scanner/world axes.
|
|
74
|
+
pivot : (3,) ndarray or None
|
|
75
|
+
Rotation center in world coordinates (origin if None).
|
|
76
|
+
|
|
77
|
+
Returns
|
|
78
|
+
-------
|
|
79
|
+
rotated_affine : (4,4) ndarray
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
A = np.asarray(affine, dtype=float)
|
|
83
|
+
|
|
84
|
+
# --- rotation matrices ---
|
|
85
|
+
Rx = np.array([
|
|
86
|
+
[1, 0, 0],
|
|
87
|
+
[0, np.cos(rad_x), -np.sin(rad_x)],
|
|
88
|
+
[0, np.sin(rad_x), np.cos(rad_x)],
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
Ry = np.array([
|
|
92
|
+
[ np.cos(rad_y), 0, np.sin(rad_y)],
|
|
93
|
+
[0, 1, 0],
|
|
94
|
+
[-np.sin(rad_y), 0, np.cos(rad_y)],
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
Rz = np.array([
|
|
98
|
+
[np.cos(rad_z), -np.sin(rad_z), 0],
|
|
99
|
+
[np.sin(rad_z), np.cos(rad_z), 0],
|
|
100
|
+
[0, 0, 1],
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
# rotation order: x -> y -> z
|
|
104
|
+
R = Rz @ Ry @ Rx
|
|
105
|
+
M, t = to_matvec(A)
|
|
106
|
+
M_new = R @ M
|
|
107
|
+
|
|
108
|
+
if pivot is None:
|
|
109
|
+
t_new = R @ t
|
|
110
|
+
else:
|
|
111
|
+
p = np.asarray(pivot, dtype=float).reshape(3,)
|
|
112
|
+
t_new = R @ t + (p - R @ p)
|
|
113
|
+
return from_matvec(M_new, t_new)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def flip_affine(
|
|
117
|
+
affine: np.ndarray,
|
|
118
|
+
flip_x: bool = False,
|
|
119
|
+
flip_y: bool = False,
|
|
120
|
+
flip_z: bool = False,
|
|
121
|
+
pivot: Optional[np.ndarray] = None,
|
|
122
|
+
) -> np.ndarray:
|
|
123
|
+
"""
|
|
124
|
+
Flip selected world axes of an affine matrix.
|
|
125
|
+
|
|
126
|
+
Applies sign flips to the x/y/z directions of the input affine's rotation
|
|
127
|
+
and adjusts the translation either about the origin (default) or about a
|
|
128
|
+
supplied 3D pivot point. This operates purely in world space; voxel shape
|
|
129
|
+
is not required.
|
|
130
|
+
"""
|
|
131
|
+
A = np.asarray(affine, dtype=float)
|
|
132
|
+
M, t = to_matvec(A)
|
|
133
|
+
|
|
134
|
+
sx = -1.0 if flip_x else 1.0
|
|
135
|
+
sy = -1.0 if flip_y else 1.0
|
|
136
|
+
sz = -1.0 if flip_z else 1.0
|
|
137
|
+
F = np.diag([sx, sy, sz])
|
|
138
|
+
|
|
139
|
+
M_new = F @ M
|
|
140
|
+
|
|
141
|
+
if pivot is None:
|
|
142
|
+
t_new = F @ t
|
|
143
|
+
else:
|
|
144
|
+
p = np.asarray(pivot, dtype=float).reshape(3,)
|
|
145
|
+
t_new = F @ t + (p - F @ p)
|
|
146
|
+
|
|
147
|
+
return from_matvec(M_new, t_new)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def flip_voxel_axis_affine(
|
|
151
|
+
affine: np.ndarray,
|
|
152
|
+
axis: int,
|
|
153
|
+
shape: Tuple[int, ...]
|
|
154
|
+
) -> np.ndarray:
|
|
155
|
+
"""
|
|
156
|
+
Flip a specific voxel axis in an affine matrix.
|
|
157
|
+
|
|
158
|
+
Negates the column corresponding to `axis` in the affine's rotation and
|
|
159
|
+
shifts the translation by `(n-1)` voxels along that axis so the flipped
|
|
160
|
+
coordinates still index the same physical space for an array of `shape`.
|
|
161
|
+
This is voxel-shape aware and differs from `flip_affine`, which flips in
|
|
162
|
+
world space without needing `shape`.
|
|
163
|
+
"""
|
|
164
|
+
A = np.asarray(affine, float)
|
|
165
|
+
M = A[:3, :3].copy()
|
|
166
|
+
t = A[:3, 3].copy()
|
|
167
|
+
|
|
168
|
+
n = int(shape[axis])
|
|
169
|
+
if n <= 1:
|
|
170
|
+
return A.copy()
|
|
171
|
+
|
|
172
|
+
col = M[:, axis].copy() # original column
|
|
173
|
+
M[:, axis] = -M[:, axis] # flip direction
|
|
174
|
+
t = t + col * (n - 1) # translation correction
|
|
175
|
+
|
|
176
|
+
out = np.eye(4)
|
|
177
|
+
out[:3, :3] = M
|
|
178
|
+
out[:3, 3] = t
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def unwrap_to_scanner_xyz(
|
|
183
|
+
affine: np.ndarray,
|
|
184
|
+
subject_type: Optional[SubjectType],
|
|
185
|
+
subject_pose: SubjectPose) -> np.ndarray:
|
|
186
|
+
"""Normalize an affine to scanner orientation for a subject pose.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
affine: (4, 4) affine in world/scanner space.
|
|
190
|
+
subject_type: Subject category. Supported values include "Biped",
|
|
191
|
+
"Quadruped", "Phantom", "OtherAnimal", and "Other". If ``None``,
|
|
192
|
+
it defaults to ``"Biped"`` for PV5.1 compatibility. Unwrapping is
|
|
193
|
+
only tested for "Biped" and "Quadruped".
|
|
194
|
+
subject_pose: Pose string formatted as
|
|
195
|
+
``"Head|Foot" + "_" + "Supine|Prone|Left|Right"``. The prefix
|
|
196
|
+
indicates head-first or feet-first entry; the suffix captures the
|
|
197
|
+
gravity orientation.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Affine reoriented to scanner L-R, bottom-to-top, front-to-back.
|
|
201
|
+
"""
|
|
202
|
+
_affine = np.asarray(affine)
|
|
203
|
+
head_or_foot, gravity = subject_pose.split('_', 1)
|
|
204
|
+
subject_type = subject_type or 'Biped' # backward compatibility with PV5.1 (subject_type == None)
|
|
205
|
+
|
|
206
|
+
if head_or_foot == "Foot":
|
|
207
|
+
_affine = rotate_affine(_affine, rad_y=np.pi)
|
|
208
|
+
|
|
209
|
+
if subject_type == "Biped":
|
|
210
|
+
# Paravision stores affine based on LPS+, but scanner coordinate is LAS+(based on subject orientation)
|
|
211
|
+
# correspond to scanner left to right(x), buttom to top(y), front to back(z) according to the operation's view
|
|
212
|
+
# simply flip y axis unwrap subject to scanner orient
|
|
213
|
+
_affine = flip_affine(_affine, flip_y=True)
|
|
214
|
+
if gravity == "Prone":
|
|
215
|
+
_affine = rotate_affine(_affine, rad_z=np.pi)
|
|
216
|
+
elif gravity == "Left":
|
|
217
|
+
_affine = rotate_affine(_affine, rad_z=-np.pi/2)
|
|
218
|
+
elif gravity == "Right":
|
|
219
|
+
_affine = rotate_affine(_affine, rad_z=np.pi/2)
|
|
220
|
+
|
|
221
|
+
elif subject_type == "Quadruped":
|
|
222
|
+
# Paravision convert affine to match LSA+ of Quadruped subject,
|
|
223
|
+
# but the scanner coordinate is RSA+(based on subject orientation)
|
|
224
|
+
_affine = flip_affine(_affine, flip_x=True)
|
|
225
|
+
if gravity == "Supine":
|
|
226
|
+
_affine = rotate_affine(_affine, rad_z=np.pi)
|
|
227
|
+
elif gravity == "Left":
|
|
228
|
+
_affine = rotate_affine(_affine, rad_z=np.pi/2)
|
|
229
|
+
elif gravity == "Right":
|
|
230
|
+
_affine = rotate_affine(_affine, rad_z=-np.pi/2)
|
|
231
|
+
|
|
232
|
+
return _affine
|
|
233
|
+
|
|
234
|
+
def wrap_to_subject_ras(affine: np.ndarray,
|
|
235
|
+
subject_type: Optional[SubjectType],
|
|
236
|
+
subject_pose: SubjectPose) -> np.ndarray:
|
|
237
|
+
"""Reorient an affine from scanner space back to a subject pose.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
affine: (4, 4) affine in world/scanner space.
|
|
241
|
+
subject_type: Subject category. Supported values include "Biped",
|
|
242
|
+
"Quadruped", "Phantom", "OtherAnimal", and "Other". If ``None``,
|
|
243
|
+
it defaults to ``"Biped"`` for PV5.1 compatibility.
|
|
244
|
+
subject_pose: Override pose string formatted as
|
|
245
|
+
``"Head|Foot" + "_" + "Supine|Prone|Left|Right"``. The prefix
|
|
246
|
+
indicates head-first or feet-first entry; the suffix captures the
|
|
247
|
+
gravity orientation.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Affine reoriented to subject RAS+.
|
|
251
|
+
"""
|
|
252
|
+
_affine = np.asarray(affine)
|
|
253
|
+
head_or_foot, gravity = subject_pose.split('_', 1)
|
|
254
|
+
|
|
255
|
+
# device back: Head / foot
|
|
256
|
+
if head_or_foot == "Foot":
|
|
257
|
+
_affine = rotate_affine(_affine, rad_y=np.pi)
|
|
258
|
+
|
|
259
|
+
subject_type = subject_type or 'Biped' # backward compatibility with PV5.1 (subject_type == None)
|
|
260
|
+
|
|
261
|
+
if subject_type == "Biped":
|
|
262
|
+
# in operators view (scanner), patient is LAS+ in scanner coordinate in "Head_Supine" position (after unwrap)
|
|
263
|
+
# step1. LAS+ (scanner coordinate) to LAI+ (subject coordinate, dicom)
|
|
264
|
+
_affine = flip_affine(_affine, flip_z=True)
|
|
265
|
+
# step2. LAI+ to RAS+
|
|
266
|
+
_affine = rotate_affine(_affine, rad_y=np.pi)
|
|
267
|
+
if gravity == "Prone":
|
|
268
|
+
_affine = rotate_affine(_affine, rad_z=np.pi)
|
|
269
|
+
elif gravity == "Left":
|
|
270
|
+
_affine = rotate_affine(_affine, rad_z=np.pi/2)
|
|
271
|
+
elif gravity == "Right":
|
|
272
|
+
_affine = rotate_affine(_affine, rad_z=-np.pi/2)
|
|
273
|
+
|
|
274
|
+
elif subject_type == "Quadruped":
|
|
275
|
+
# in unwrapped view (scanner), subject is RSA+ in "Head_Prone" position
|
|
276
|
+
# step1. RSA+ to RSP+
|
|
277
|
+
_affine = flip_affine(_affine, flip_z=True)
|
|
278
|
+
# step2. RSP+ to RAS+
|
|
279
|
+
_affine = rotate_affine(_affine, rad_x=np.pi/2)
|
|
280
|
+
if gravity == "Supine":
|
|
281
|
+
_affine = rotate_affine(_affine, rad_z=np.pi)
|
|
282
|
+
elif gravity == "Left":
|
|
283
|
+
_affine = rotate_affine(_affine, rad_z=-np.pi/2)
|
|
284
|
+
elif gravity == "Right":
|
|
285
|
+
_affine = rotate_affine(_affine, rad_z=np.pi/2)
|
|
286
|
+
|
|
287
|
+
return _affine
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def resolve_matvec_and_shape(visu_pars,
|
|
291
|
+
spack_idx: int,
|
|
292
|
+
num_slices: List[int],
|
|
293
|
+
slice_thickness: List[Union[float, int]]) -> Tuple[np.ndarray, np.ndarray, Tuple[int, ...]]:
|
|
294
|
+
"""
|
|
295
|
+
Build an affine matrix, origin vector, and volume shape for a Bruker dataset.
|
|
296
|
+
|
|
297
|
+
Parameters
|
|
298
|
+
----------
|
|
299
|
+
visu_pars : mapping
|
|
300
|
+
Must contain `VisuCoreDim`, `VisuCoreOrientation`, `VisuCorePosition`,
|
|
301
|
+
`VisuCoreExtent`, `VisuCoreSize`.
|
|
302
|
+
spack_idx : int
|
|
303
|
+
Slice-package index into `num_slices` and `slice_thickness`.
|
|
304
|
+
num_slices : sequence[int]
|
|
305
|
+
Number of slices per package; length must match available orientations/positions.
|
|
306
|
+
slice_thickness : sequence[float]
|
|
307
|
+
Thickness per slice package (same length as `num_slices`).
|
|
308
|
+
|
|
309
|
+
Returns
|
|
310
|
+
-------
|
|
311
|
+
mat : np.ndarray
|
|
312
|
+
3x3 affine matrix whose columns are row/col/slice direction vectors scaled
|
|
313
|
+
by voxel resolutions.
|
|
314
|
+
vec : np.ndarray
|
|
315
|
+
Reference origin (world coordinates) for the chosen slice package.
|
|
316
|
+
shape : Tuple[int]
|
|
317
|
+
3D matrix size
|
|
318
|
+
|
|
319
|
+
Raises
|
|
320
|
+
------
|
|
321
|
+
ValueError on shape mismatch or missing orientations/positions.
|
|
322
|
+
IndexError if `spack_idx` is out of range.
|
|
323
|
+
"""
|
|
324
|
+
dim = visu_pars.get("VisuCoreDim")
|
|
325
|
+
rotate = np.asarray(visu_pars.get("VisuCoreOrientation"), dtype=float)
|
|
326
|
+
origin = np.asarray(visu_pars.get("VisuCorePosition"), dtype=float)
|
|
327
|
+
extent = np.asarray(visu_pars.get("VisuCoreExtent"), dtype=float)
|
|
328
|
+
shape = np.asarray(visu_pars.get("VisuCoreSize"), dtype=float)
|
|
329
|
+
|
|
330
|
+
if dim == 2:
|
|
331
|
+
num_slicepack = len(num_slices)
|
|
332
|
+
|
|
333
|
+
if spack_idx < 0 or spack_idx >= num_slicepack:
|
|
334
|
+
raise IndexError(f"spack_idx out of range: {spack_idx} (num packs: {num_slicepack})")
|
|
335
|
+
|
|
336
|
+
total_slices = int(np.sum(np.asarray(num_slices, dtype=int)))
|
|
337
|
+
spack_slice_start = int(np.sum(np.asarray(num_slices[:spack_idx], dtype=int)))
|
|
338
|
+
spack_slice_end = spack_slice_start + int(num_slices[spack_idx])
|
|
339
|
+
|
|
340
|
+
def _select_slice_entries(arr: np.ndarray, *, width: int, name: str) -> np.ndarray:
|
|
341
|
+
arr = np.asarray(arr, dtype=float)
|
|
342
|
+
if arr.ndim == 1:
|
|
343
|
+
if arr.size == width:
|
|
344
|
+
arr = arr.reshape((1, width))
|
|
345
|
+
else:
|
|
346
|
+
raise ValueError(f"{name} has shape {arr.shape}, expected (*, {width})")
|
|
347
|
+
if arr.ndim != 2 or arr.shape[1] != width:
|
|
348
|
+
raise ValueError(f"{name} has shape {arr.shape}, expected (*, {width})")
|
|
349
|
+
|
|
350
|
+
# Prefer per-slice entries (concatenated across slice packs).
|
|
351
|
+
if arr.shape[0] > total_slices:
|
|
352
|
+
if not np.allclose(arr[:total_slices], arr[0], atol=0, rtol=0):
|
|
353
|
+
logger.warning(
|
|
354
|
+
"%s has %s entries but expected %s; using the first %s entries.",
|
|
355
|
+
name,
|
|
356
|
+
arr.shape[0],
|
|
357
|
+
total_slices,
|
|
358
|
+
total_slices,
|
|
359
|
+
)
|
|
360
|
+
arr = arr[:total_slices, :]
|
|
361
|
+
|
|
362
|
+
if arr.shape[0] == total_slices:
|
|
363
|
+
return arr[spack_slice_start:spack_slice_end, :]
|
|
364
|
+
|
|
365
|
+
# Fallback: per-pack entries (one entry per slice pack).
|
|
366
|
+
if arr.shape[0] == num_slicepack:
|
|
367
|
+
if int(num_slices[spack_idx]) != 1:
|
|
368
|
+
raise ValueError(
|
|
369
|
+
f"{name} provides one entry per slice pack ({num_slicepack}) "
|
|
370
|
+
f"but pack {spack_idx} has {num_slices[spack_idx]} slices; "
|
|
371
|
+
"per-slice entries are required to resolve slice positions."
|
|
372
|
+
)
|
|
373
|
+
return arr[spack_idx:spack_idx + 1, :]
|
|
374
|
+
|
|
375
|
+
raise ValueError(
|
|
376
|
+
f"{name} has {arr.shape[0]} entries, expected {total_slices} (per-slice) "
|
|
377
|
+
f"or {num_slicepack} (per-pack); method num_slices={num_slices}."
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
_rotate = _select_slice_entries(rotate, width=9, name="VisuCoreOrientation")
|
|
381
|
+
_origin = _select_slice_entries(origin, width=3, name="VisuCorePosition")
|
|
382
|
+
_num_slices = num_slices[spack_idx]
|
|
383
|
+
_slice_thickness = slice_thickness[spack_idx]
|
|
384
|
+
|
|
385
|
+
if _rotate.shape[0] > 1 and not np.allclose(_rotate, _rotate[0], atol=0, rtol=0):
|
|
386
|
+
logger.warning(
|
|
387
|
+
"VisuCoreOrientation varies across slices in pack %s; using the first slice orientation.",
|
|
388
|
+
spack_idx,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
row = _rotate[0, 0:3]
|
|
392
|
+
col = _rotate[0, 3:6]
|
|
393
|
+
slc = _rotate[0, 6:9]
|
|
394
|
+
|
|
395
|
+
n = slc.astype(float)
|
|
396
|
+
n = n / np.linalg.norm(n)
|
|
397
|
+
|
|
398
|
+
if _num_slices > 1:
|
|
399
|
+
# project each slice position onto slice normal
|
|
400
|
+
s = _origin @ n # shape (num_slices,)
|
|
401
|
+
idx = int(np.argmin(s))
|
|
402
|
+
vec = _origin[idx]
|
|
403
|
+
else:
|
|
404
|
+
vec = _origin[0]
|
|
405
|
+
shape = np.append(shape, _num_slices)
|
|
406
|
+
extent = np.append(extent, _num_slices * _slice_thickness)
|
|
407
|
+
else:
|
|
408
|
+
_rotate = np.squeeze(rotate)
|
|
409
|
+
row = _rotate[0:3]
|
|
410
|
+
col = _rotate[3:6]
|
|
411
|
+
slc = _rotate[6:9]
|
|
412
|
+
vec = np.squeeze(origin)
|
|
413
|
+
|
|
414
|
+
rot = np.column_stack([row, col, slc])
|
|
415
|
+
resols = extent / shape
|
|
416
|
+
mat = rot * resols.reshape(1, 3)
|
|
417
|
+
return mat, vec, tuple(shape.astype(int).tolist())
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def resolve_slice_pack(
|
|
421
|
+
scan: "Scan",
|
|
422
|
+
) -> Optional[ResolvedSlicePack]:
|
|
423
|
+
"""Compute slice pack layout across Paravision versions.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
scan: Scan node providing method.
|
|
427
|
+
Returns:
|
|
428
|
+
SlicePackInfo with pack counts, slices per pack, distances, gaps; None if not a spatial data case.
|
|
429
|
+
"""
|
|
430
|
+
try:
|
|
431
|
+
method: "Parameters" = get_file(scan, 'method')
|
|
432
|
+
except FileNotFoundError:
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
slice_pack_info = method.search_keys('spack')
|
|
436
|
+
if len(slice_pack_info) == 0:
|
|
437
|
+
# no slice pack
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
num_slice_packs = method.get('PVM_NSPacks') or 1
|
|
441
|
+
num_slices = cast(Union[List[Any], np.ndarray],
|
|
442
|
+
return_alt_val_if_none(method.get('PVM_SPackArrNSlices'), [1]))
|
|
443
|
+
slice_thickness = cast(Union[List[Any], np.ndarray],
|
|
444
|
+
return_alt_val_if_none(method.get('PVM_SPackArrSliceDistance'), [0]))
|
|
445
|
+
slice_gap = cast(Union[List[Any], np.ndarray],
|
|
446
|
+
return_alt_val_if_none(method.get('PVM_SPackArrSliceGap'), [0]))
|
|
447
|
+
|
|
448
|
+
def _normalize_ndarray_to_list(val: Union[List[Any], np.ndarray]) -> List[Any]:
|
|
449
|
+
if isinstance(val, np.ndarray):
|
|
450
|
+
return val.tolist()
|
|
451
|
+
return val
|
|
452
|
+
|
|
453
|
+
result: ResolvedSlicePack = {
|
|
454
|
+
'num_slice_packs': num_slice_packs,
|
|
455
|
+
'num_slices': _normalize_ndarray_to_list(num_slices),
|
|
456
|
+
'slice_thickness': _normalize_ndarray_to_list(slice_thickness),
|
|
457
|
+
'slice_gap': _normalize_ndarray_to_list(slice_gap),
|
|
458
|
+
'unit': 'mm',
|
|
459
|
+
}
|
|
460
|
+
return result
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def get_subject_type_and_position(visu_pars: "Parameters") -> Tuple[Optional[SubjectType], SubjectPose]:
|
|
464
|
+
subj_type = visu_pars.get("VisuSubjectType")
|
|
465
|
+
subj_position = visu_pars.get("VisuSubjectPosition")
|
|
466
|
+
return cast(Optional[SubjectType], subj_type), cast(SubjectPose, subj_position)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def resolve(
|
|
470
|
+
scan: "Scan",
|
|
471
|
+
reco_id: int = 1,
|
|
472
|
+
decimals: int = 6,
|
|
473
|
+
unwrap_pose: bool = False
|
|
474
|
+
) -> Optional[ResolvedAffine]:
|
|
475
|
+
"""Resolve per-slice-pack affines for a scan.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
scan: Scan node containing the target reco.
|
|
479
|
+
reco_id: Reco id to process (default: 1).
|
|
480
|
+
decimals: Number of decimals to round affines for stability.
|
|
481
|
+
unwrap_pose: If True, reorient affines to scanner space based on subject pose.
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
AffineInfo with per-pack affines, slice counts, subject metadata, and
|
|
485
|
+
whether pose unwrapping was applied; None if required files are missing.
|
|
486
|
+
"""
|
|
487
|
+
|
|
488
|
+
reco: "Reco" = get_reco(scan, reco_id)
|
|
489
|
+
try:
|
|
490
|
+
acqp = get_file(scan, 'acqp')
|
|
491
|
+
method = get_file(scan, 'method')
|
|
492
|
+
visu_pars = get_file(reco, 'visu_pars')
|
|
493
|
+
except FileNotFoundError:
|
|
494
|
+
return None
|
|
495
|
+
|
|
496
|
+
slice_orient = method.get('PVM_SPackArrSliceOrient')
|
|
497
|
+
phase_dir = acqp.get('ACQ_scaling_phase') or 1
|
|
498
|
+
|
|
499
|
+
slice_info = resolve_slice_pack(scan)
|
|
500
|
+
if not slice_info:
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
num_slice_packs = slice_info['num_slice_packs']
|
|
504
|
+
num_slices = slice_info['num_slices']
|
|
505
|
+
slice_thickness = slice_info['slice_thickness']
|
|
506
|
+
slice_gap = slice_info['slice_gap']
|
|
507
|
+
|
|
508
|
+
# slice thickness = image shickness + slice gap
|
|
509
|
+
slice_thickness = [t + slice_gap[i] for i, t in enumerate(slice_thickness)]
|
|
510
|
+
|
|
511
|
+
subj_type, subj_position = get_subject_type_and_position(visu_pars)
|
|
512
|
+
|
|
513
|
+
affines = []
|
|
514
|
+
shape: Optional[Tuple[int, ...]] = None
|
|
515
|
+
for spack_idx in range(num_slice_packs):
|
|
516
|
+
spack_slice_orient = slice_orient if num_slice_packs == 1 else slice_orient[spack_idx]
|
|
517
|
+
spack_mat, spack_vec, shape = resolve_matvec_and_shape(visu_pars, spack_idx, num_slices, slice_thickness)
|
|
518
|
+
|
|
519
|
+
affine = from_matvec(spack_mat, spack_vec)
|
|
520
|
+
if phase_dir < 0:
|
|
521
|
+
affine = flip_voxel_axis_affine(affine[:], axis=1, shape=shape)
|
|
522
|
+
if spack_slice_orient == 'coronal':
|
|
523
|
+
affine = flip_voxel_axis_affine(affine[:], axis=2, shape=shape)
|
|
524
|
+
if unwrap_pose:
|
|
525
|
+
affine = unwrap_to_scanner_xyz(affine[:], subj_type, subj_position)
|
|
526
|
+
affines.append(np.round(affine, decimals=decimals))
|
|
527
|
+
|
|
528
|
+
if shape is not None and num_slice_packs == 1 and num_slices[0] == 1 and shape[2] != num_slices[0]:
|
|
529
|
+
num_slices = [shape[2]]
|
|
530
|
+
|
|
531
|
+
result: ResolvedAffine = {
|
|
532
|
+
'num_slice_packs': num_slice_packs,
|
|
533
|
+
'affines': affines,
|
|
534
|
+
'num_slices': num_slices,
|
|
535
|
+
'subject_type': subj_type,
|
|
536
|
+
'subject_position': subj_position,
|
|
537
|
+
'is_unwrapped': unwrap_pose,
|
|
538
|
+
}
|
|
539
|
+
return result
|