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.
Files changed (113) hide show
  1. brkraw/__init__.py +9 -3
  2. brkraw/apps/__init__.py +12 -0
  3. brkraw/apps/addon/__init__.py +30 -0
  4. brkraw/apps/addon/core.py +35 -0
  5. brkraw/apps/addon/dependencies.py +402 -0
  6. brkraw/apps/addon/installation.py +500 -0
  7. brkraw/apps/addon/io.py +21 -0
  8. brkraw/apps/hook/__init__.py +25 -0
  9. brkraw/apps/hook/core.py +636 -0
  10. brkraw/apps/loader/__init__.py +10 -0
  11. brkraw/apps/loader/core.py +622 -0
  12. brkraw/apps/loader/formatter.py +288 -0
  13. brkraw/apps/loader/helper.py +797 -0
  14. brkraw/apps/loader/info/__init__.py +11 -0
  15. brkraw/apps/loader/info/scan.py +85 -0
  16. brkraw/apps/loader/info/scan.yaml +90 -0
  17. brkraw/apps/loader/info/study.py +69 -0
  18. brkraw/apps/loader/info/study.yaml +156 -0
  19. brkraw/apps/loader/info/transform.py +92 -0
  20. brkraw/apps/loader/types.py +220 -0
  21. brkraw/cli/__init__.py +5 -0
  22. brkraw/cli/commands/__init__.py +2 -0
  23. brkraw/cli/commands/addon.py +327 -0
  24. brkraw/cli/commands/config.py +205 -0
  25. brkraw/cli/commands/convert.py +903 -0
  26. brkraw/cli/commands/hook.py +348 -0
  27. brkraw/cli/commands/info.py +74 -0
  28. brkraw/cli/commands/init.py +214 -0
  29. brkraw/cli/commands/params.py +106 -0
  30. brkraw/cli/commands/prune.py +288 -0
  31. brkraw/cli/commands/session.py +371 -0
  32. brkraw/cli/hook_args.py +80 -0
  33. brkraw/cli/main.py +83 -0
  34. brkraw/cli/utils.py +60 -0
  35. brkraw/core/__init__.py +13 -0
  36. brkraw/core/config.py +380 -0
  37. brkraw/core/entrypoints.py +25 -0
  38. brkraw/core/formatter.py +367 -0
  39. brkraw/core/fs.py +495 -0
  40. brkraw/core/jcamp.py +600 -0
  41. brkraw/core/layout.py +451 -0
  42. brkraw/core/parameters.py +781 -0
  43. brkraw/core/zip.py +1121 -0
  44. brkraw/dataclasses/__init__.py +14 -0
  45. brkraw/dataclasses/node.py +139 -0
  46. brkraw/dataclasses/reco.py +33 -0
  47. brkraw/dataclasses/scan.py +61 -0
  48. brkraw/dataclasses/study.py +131 -0
  49. brkraw/default/__init__.py +3 -0
  50. brkraw/default/pruner_specs/deid4share.yaml +42 -0
  51. brkraw/default/rules/00_default.yaml +4 -0
  52. brkraw/default/specs/metadata_dicom.yaml +236 -0
  53. brkraw/default/specs/metadata_transforms.py +92 -0
  54. brkraw/resolver/__init__.py +7 -0
  55. brkraw/resolver/affine.py +539 -0
  56. brkraw/resolver/datatype.py +69 -0
  57. brkraw/resolver/fid.py +90 -0
  58. brkraw/resolver/helpers.py +36 -0
  59. brkraw/resolver/image.py +188 -0
  60. brkraw/resolver/nifti.py +370 -0
  61. brkraw/resolver/shape.py +235 -0
  62. brkraw/schema/__init__.py +3 -0
  63. brkraw/schema/context_map.yaml +62 -0
  64. brkraw/schema/meta.yaml +57 -0
  65. brkraw/schema/niftiheader.yaml +95 -0
  66. brkraw/schema/pruner.yaml +55 -0
  67. brkraw/schema/remapper.yaml +128 -0
  68. brkraw/schema/rules.yaml +154 -0
  69. brkraw/specs/__init__.py +10 -0
  70. brkraw/specs/hook/__init__.py +12 -0
  71. brkraw/specs/hook/logic.py +31 -0
  72. brkraw/specs/hook/validator.py +22 -0
  73. brkraw/specs/meta/__init__.py +5 -0
  74. brkraw/specs/meta/validator.py +156 -0
  75. brkraw/specs/pruner/__init__.py +15 -0
  76. brkraw/specs/pruner/logic.py +361 -0
  77. brkraw/specs/pruner/validator.py +119 -0
  78. brkraw/specs/remapper/__init__.py +27 -0
  79. brkraw/specs/remapper/logic.py +924 -0
  80. brkraw/specs/remapper/validator.py +314 -0
  81. brkraw/specs/rules/__init__.py +6 -0
  82. brkraw/specs/rules/logic.py +263 -0
  83. brkraw/specs/rules/validator.py +103 -0
  84. brkraw-0.5.0.dist-info/METADATA +81 -0
  85. brkraw-0.5.0.dist-info/RECORD +88 -0
  86. {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info}/WHEEL +1 -2
  87. brkraw-0.5.0.dist-info/entry_points.txt +13 -0
  88. brkraw/lib/__init__.py +0 -4
  89. brkraw/lib/backup.py +0 -641
  90. brkraw/lib/bids.py +0 -0
  91. brkraw/lib/errors.py +0 -125
  92. brkraw/lib/loader.py +0 -1220
  93. brkraw/lib/orient.py +0 -194
  94. brkraw/lib/parser.py +0 -48
  95. brkraw/lib/pvobj.py +0 -301
  96. brkraw/lib/reference.py +0 -245
  97. brkraw/lib/utils.py +0 -471
  98. brkraw/scripts/__init__.py +0 -0
  99. brkraw/scripts/brk_backup.py +0 -106
  100. brkraw/scripts/brkraw.py +0 -744
  101. brkraw/ui/__init__.py +0 -0
  102. brkraw/ui/config.py +0 -17
  103. brkraw/ui/main_win.py +0 -214
  104. brkraw/ui/previewer.py +0 -225
  105. brkraw/ui/scan_info.py +0 -72
  106. brkraw/ui/scan_list.py +0 -73
  107. brkraw/ui/subj_info.py +0 -128
  108. brkraw-0.3.11.dist-info/METADATA +0 -25
  109. brkraw-0.3.11.dist-info/RECORD +0 -28
  110. brkraw-0.3.11.dist-info/entry_points.txt +0 -3
  111. brkraw-0.3.11.dist-info/top_level.txt +0 -2
  112. tests/__init__.py +0 -0
  113. {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,7 @@
1
+ from __future__ import annotations
2
+
3
+ from . import image
4
+
5
+ __all__ = [
6
+ 'image',
7
+ ]
@@ -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