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,797 @@
1
+ """Internal helper functions for BrukerLoader.
2
+
3
+ Last updated: 2025-12-30
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from types import MethodType
9
+ from functools import partial
10
+ import inspect
11
+ from typing import TYPE_CHECKING, Optional, Tuple, Union, Any, Mapping, cast, List, Dict, Literal
12
+ from pathlib import Path
13
+ from warnings import warn
14
+ import logging
15
+
16
+ import numpy as np
17
+ from nibabel.nifti1 import Nifti1Image
18
+
19
+ from ...core.config import resolve_root
20
+ from ...core.parameters import Parameters
21
+ from ...specs.remapper import load_spec, map_parameters, load_context_map, apply_context_map
22
+ from ...specs.rules import load_rules, select_rule_use
23
+ from ...dataclasses import Reco, Scan, Study
24
+ from .types import ScanLoader
25
+ from ...specs import hook as converter_core
26
+ from ...resolver import affine as affine_resolver
27
+ from ...resolver import image as image_resolver
28
+ from ...resolver import fid as fid_resolver
29
+ from ...resolver import nifti as nifti_resolver
30
+ from ...resolver.helpers import get_file
31
+
32
+ if TYPE_CHECKING:
33
+ from ...resolver.nifti import Nifti1HeaderContents
34
+ from .types import SubjectType, SubjectPose, XYZUNIT, TUNIT, AffineReturn, AffineSpace
35
+
36
+ logger = logging.getLogger("brkraw")
37
+
38
+ __all__ = [
39
+ "resolve_data_and_affine",
40
+ "search_parameters",
41
+ "get_dataobj",
42
+ "get_affine",
43
+ "get_nifti1image",
44
+ "convert",
45
+ "get_metadata",
46
+ "apply_converter_hook",
47
+ "make_dir",
48
+ ]
49
+
50
+
51
+ def make_dir(names: List[str]):
52
+ """Return a stable __dir__ function for a module."""
53
+ def _dir() -> List[str]:
54
+ return sorted(names)
55
+ return _dir
56
+
57
+
58
+ def _resolve_reco_id(
59
+ scan: Union["Scan", "ScanLoader"],
60
+ reco_id: Optional[int],
61
+ ) -> Optional[int]:
62
+ """Resolve a reco id, defaulting to the first available when None."""
63
+ scan = cast(ScanLoader, scan)
64
+ available = list(scan.avail.keys())
65
+ if not available:
66
+ logger.warning("No reco ids available for scan %s", getattr(scan, "scan_id", "?"))
67
+ return None
68
+ if reco_id is None:
69
+ return available[0]
70
+ if reco_id not in scan.avail:
71
+ logger.warning(
72
+ "Reco id %s not available for scan %s (available: %s)",
73
+ reco_id,
74
+ getattr(scan, "scan_id", "?"),
75
+ available,
76
+ )
77
+ return None
78
+ return reco_id
79
+
80
+
81
+ def resolve_data_and_affine(
82
+ scan: Union["Scan", "ScanLoader"],
83
+ reco_id: Optional[int] = None,
84
+ *,
85
+ affine_decimals: int = 6,
86
+ ):
87
+ """Populate per-reco image/affine resolver outputs on a scan.
88
+
89
+ Args:
90
+ scan: Scan node to attach image/affine info.
91
+ reco_id: Reco identifier to resolve (default: 1).
92
+ affine_decimals: Decimal rounding applied to resolved affines.
93
+ """
94
+ scan = cast(ScanLoader, scan)
95
+
96
+ reco_ids = [reco_id] if reco_id is not None else list(scan.avail.keys())
97
+ if not reco_ids:
98
+ logger.warning("No reco ids available to resolve for scan %s", getattr(scan, "scan_id", "?"))
99
+ return
100
+
101
+ for rid in reco_ids:
102
+ if rid not in scan.avail:
103
+ logger.warning(
104
+ "Reco id %s not available for scan %s (available: %s)",
105
+ rid,
106
+ getattr(scan, "scan_id", "?"),
107
+ list(scan.avail.keys()),
108
+ )
109
+ continue
110
+ try:
111
+ image_info = image_resolver.resolve(scan, rid)
112
+ except Exception as exc:
113
+ logger.warning(
114
+ "Failed to resolve image data for scan %s reco %s: %s",
115
+ getattr(scan, "scan_id", "?"),
116
+ rid,
117
+ exc,
118
+ )
119
+ image_info = None
120
+ try:
121
+ # store subject-view affines (scanner unwrap happens in get_affine)
122
+ affine_info = affine_resolver.resolve(
123
+ scan, rid, decimals=affine_decimals, unwrap_pose=False,
124
+ )
125
+ except Exception as exc:
126
+ logger.warning(
127
+ "Failed to resolve affine for scan %s reco %s: %s",
128
+ getattr(scan, "scan_id", "?"),
129
+ rid,
130
+ exc,
131
+ )
132
+ affine_info = None
133
+
134
+ if hasattr(scan, "image_info"):
135
+ scan.image_info[rid] = image_info
136
+ else:
137
+ setattr(scan, "image_info", {rid: image_info})
138
+ if hasattr(scan, "affine_info"):
139
+ scan.affine_info[rid] = affine_info
140
+ else:
141
+ setattr(scan, "affine_info", {rid: affine_info})
142
+ scan.get_fid = MethodType(fid_resolver.resolve, scan)
143
+
144
+
145
+ def search_parameters(
146
+ self: Union[Study, Scan, Reco],
147
+ key: str,
148
+ file: Optional[Union[str, List[str]]] = None,
149
+ scan_id: Optional[int] = None,
150
+ reco_id: Optional[int] = None,
151
+ ) -> Optional[dict]:
152
+ """Search parameter files for keys on Study/Scan/Reco objects.
153
+
154
+ Results are grouped by filename. When searching a Study/Scan without
155
+ reco_id, scan and reco hits are merged as
156
+ `{filename: {"scan": {...}, "reco_<id>": {...}}}`. With a specific reco_id
157
+ (or Reco), results stay flat as `{filename: {matched_key: value}}`.
158
+ Missing files are ignored; non-parameter files raise TypeError.
159
+
160
+ Args:
161
+ self: Study, Scan, or Reco instance.
162
+ key: Parameter key to search for.
163
+ file: Filename or list of filenames to search (default: common set).
164
+ scan_id: Scan id (required when searching from Study).
165
+ reco_id: Reco id (optional; flattens results for that reco).
166
+
167
+ Returns:
168
+ Mapping of filename to found values, or None if no hits.
169
+ """
170
+
171
+ files = ["method", "acqp", "visu_pars", "reco"] if file is None else file
172
+ files = [files] if isinstance(files, str) else list(files)
173
+
174
+ def load_parameters(obj: Union[Study, Scan, Reco], filename: str) -> Optional[Parameters]:
175
+ try:
176
+ params = get_file(obj, filename)
177
+ except FileNotFoundError:
178
+ return None
179
+ if not isinstance(params, Parameters):
180
+ raise TypeError(f"Not a Paravision parameter file: {filename}")
181
+ return params
182
+
183
+ def flatten_matches(matches: List[dict]) -> dict:
184
+ flat: dict = {}
185
+ for entry in matches:
186
+ flat.update(entry)
187
+ return flat
188
+
189
+ def search_node(node: Union[Study, Scan, Reco]) -> Dict[str, dict]:
190
+ hits: Dict[str, dict] = {}
191
+ for fname in files:
192
+ params = load_parameters(node, fname)
193
+ if params is None:
194
+ continue
195
+ matches = params.search_keys(key)
196
+ if matches:
197
+ hits[fname] = flatten_matches(matches)
198
+ return hits
199
+
200
+ def search_recos(scan_obj: Scan) -> Dict[int, Dict[str, dict]]:
201
+ reco_hits: Dict[int, Dict[str, dict]] = {}
202
+ for rid, reco in scan_obj.avail.items():
203
+ hits = search_node(reco)
204
+ if hits:
205
+ reco_hits[rid] = hits
206
+ return reco_hits
207
+
208
+ def merge_scan_and_recos(
209
+ scan_hits: Dict[str, dict], reco_hits: Dict[int, Dict[str, dict]]
210
+ ) -> Dict[str, Union[Dict[str, dict], dict]]:
211
+ """Merge scan/reco hits by filename.
212
+
213
+ Args:
214
+ scan_hits: Per-filename hits from the scan object.
215
+ reco_hits: Per-reco hits keyed by reco id.
216
+
217
+ Returns:
218
+ Merged mapping keyed by filename.
219
+ """
220
+ if not scan_hits and not reco_hits:
221
+ return {}
222
+
223
+ merged: Dict[str, Union[Dict[str, dict], dict]] = {}
224
+ all_fnames = set(scan_hits) | {fname for rh in reco_hits.values() for fname in rh}
225
+ for fname in all_fnames:
226
+ scan_hit = scan_hits.get(fname)
227
+ reco_for_fname = {
228
+ f"reco_{rid}": rhits[fname]
229
+ for rid, rhits in reco_hits.items()
230
+ if fname in rhits
231
+ }
232
+ if reco_for_fname:
233
+ merged[fname] = {}
234
+ if scan_hit:
235
+ merged[fname]["scan"] = scan_hit
236
+ merged[fname].update(reco_for_fname)
237
+ elif scan_hit:
238
+ merged[fname] = scan_hit
239
+ return merged
240
+
241
+ if isinstance(self, Study):
242
+ if scan_id is None:
243
+ warn("To search from Study object, specifying <scan_id> is required.")
244
+ return None
245
+ scan = self.get_scan(scan_id)
246
+ scan_hits = search_node(scan)
247
+ if reco_id is None:
248
+ reco_hits = search_recos(scan)
249
+ merged = merge_scan_and_recos(scan_hits, reco_hits)
250
+ return merged or None
251
+ # specific reco: keep flat
252
+ result: Dict[str, dict] = {}
253
+ if scan_hits:
254
+ result.update(scan_hits)
255
+ reco = scan.get_reco(reco_id)
256
+ reco_hits = search_node(reco)
257
+ if reco_hits:
258
+ result.update(reco_hits)
259
+ return result or None
260
+
261
+ if isinstance(self, Scan):
262
+ scan_hits = search_node(self)
263
+ if reco_id is None:
264
+ reco_hits = search_recos(self)
265
+ merged = merge_scan_and_recos(scan_hits, reco_hits)
266
+ return merged or None
267
+ # specific reco: keep flat
268
+ result: Dict[str, dict] = {}
269
+ if scan_hits:
270
+ result.update(scan_hits)
271
+ reco_hits = search_node(self.get_reco(reco_id))
272
+ if reco_hits:
273
+ result.update(reco_hits)
274
+ return result or None
275
+
276
+ if isinstance(self, Reco):
277
+ reco_hits = search_node(self)
278
+ return reco_hits or None
279
+
280
+ return None
281
+
282
+
283
+ def _finalize_affines(
284
+ affines: list[np.ndarray],
285
+ num_slice_packs: int,
286
+ decimals: Optional[int],
287
+ ) -> AffineReturn:
288
+ if num_slice_packs == 1:
289
+ affine = affines[0]
290
+ if decimals is not None:
291
+ affine = np.round(affine, decimals=decimals)
292
+ return affine
293
+
294
+ if decimals is not None:
295
+ return tuple(np.round(a, decimals=decimals) for a in affines)
296
+
297
+ return tuple(affines)
298
+
299
+
300
+ def get_dataobj(
301
+ self: Union["Scan", "ScanLoader"],
302
+ reco_id: Optional[int] = None,
303
+ **_: Any,
304
+ ) -> Optional[Union[Tuple["np.ndarray", ...], "np.ndarray"]]:
305
+ """Return reconstructed data for a reco, split by slice pack if needed.
306
+
307
+ Args:
308
+ self: Scan or ScanLoader instance.
309
+ reco_id: Reco identifier to read (defaults to the first available).
310
+
311
+ Returns:
312
+ Single ndarray when one slice pack exists; otherwise a tuple of arrays.
313
+ Returns None when required metadata is unavailable.
314
+ """
315
+ if not hasattr(self, "image_info") or not hasattr(self, "affine_info"):
316
+ return None
317
+ self = cast(ScanLoader, self)
318
+ resolved_reco_id = _resolve_reco_id(self, reco_id)
319
+ if resolved_reco_id is None:
320
+ return None
321
+ affine_info = self.affine_info.get(resolved_reco_id)
322
+ image_info = self.image_info.get(resolved_reco_id)
323
+ if affine_info is None or image_info is None:
324
+ return None
325
+
326
+ num_slices = affine_info["num_slices"]
327
+ dataobj = image_info["dataobj"]
328
+
329
+ slice_pack = []
330
+ slice_offset = 0
331
+ for _num_slices in num_slices:
332
+ _dataobj = dataobj[:, :, slice(slice_offset, slice_offset + _num_slices)]
333
+ slice_offset += _num_slices
334
+ slice_pack.append(_dataobj)
335
+
336
+ if len(slice_pack) == 1:
337
+ return slice_pack[0]
338
+ return tuple(slice_pack)
339
+
340
+
341
+ def get_affine(
342
+ self: Union["Scan", "ScanLoader"],
343
+ reco_id: Optional[int] = None,
344
+ *,
345
+ space: AffineSpace = "subject_ras",
346
+ override_subject_type: Optional["SubjectType"] = None,
347
+ override_subject_pose: Optional["SubjectPose"] = None,
348
+ decimals: Optional[int] = None,
349
+ **kwargs: Any,
350
+ ) -> AffineReturn:
351
+ """
352
+ Return affine(s) for a reco in the requested coordinate space.
353
+
354
+ Spaces:
355
+ - "raw": Return the affine(s) as stored (no transforms applied).
356
+ - "scanner": Return affine(s) in scanner XYZ (unwrapped).
357
+ - "subject_ras": Return affine(s) in subject-view RAS (wrap to subject pose/type).
358
+
359
+ Overrides:
360
+ - override_subject_type and override_subject_pose are only valid when space="subject_ras".
361
+ Overrides are applied during wrapping to subject RAS.
362
+
363
+ Args:
364
+ self: Scan or ScanLoader instance.
365
+ reco_id: Reco identifier to read (defaults to the first available).
366
+ space: Output space: "raw", "scanner", or "subject_ras" (default: "subject_ras").
367
+ override_subject_type: Optional subject type override (only for "subject_ras").
368
+ override_subject_pose: Optional subject pose override (only for "subject_ras").
369
+ decimals: Optional decimal rounding applied to returned affines.
370
+
371
+ Returns:
372
+ Single affine matrix when one slice pack exists; otherwise a tuple of affines.
373
+ Returns None when affine info is unavailable.
374
+
375
+ Raises:
376
+ ValueError: If overrides are provided when space is not "subject_ras".
377
+ """
378
+ if not hasattr(self, "affine_info"):
379
+ return None
380
+
381
+ self = cast("ScanLoader", self)
382
+ resolved_reco_id = _resolve_reco_id(self, reco_id)
383
+ if resolved_reco_id is None:
384
+ return None
385
+
386
+ affine_info = self.affine_info.get(resolved_reco_id)
387
+ if affine_info is None:
388
+ return None
389
+
390
+ num_slice_packs = affine_info["num_slice_packs"]
391
+ affines = list(affine_info["affines"]) # make a copy-like list
392
+
393
+ is_override = (override_subject_type is not None) or (override_subject_pose is not None)
394
+ if is_override and space != "subject_ras":
395
+ raise ValueError(
396
+ "override_subject_type/override_subject_pose is only supported when space='subject_ras'."
397
+ )
398
+
399
+ # "raw" does not need subject info
400
+ if space == "raw":
401
+ result = _finalize_affines(affines, num_slice_packs, decimals)
402
+ return _apply_affine_post_transform(result, kwargs=kwargs)
403
+
404
+ # Need subject type/pose for unwrap and wrap
405
+ visu_pars = get_file(self.avail[resolved_reco_id], "visu_pars")
406
+ subj_type, subj_pose = affine_resolver.get_subject_type_and_position(visu_pars)
407
+
408
+ # Step 1: unwrap to scanner XYZ
409
+ affines_scanner = [
410
+ affine_resolver.unwrap_to_scanner_xyz(affine, subj_type, subj_pose)
411
+ for affine in affines
412
+ ]
413
+
414
+ if space == "scanner":
415
+ result = _finalize_affines(affines_scanner, num_slice_packs, decimals)
416
+ return _apply_affine_post_transform(result, kwargs=kwargs)
417
+
418
+ # Step 2: wrap to subject RAS (optionally with override)
419
+ use_type = override_subject_type or subj_type
420
+ use_pose = override_subject_pose or subj_pose
421
+
422
+ affines_subject_ras = [
423
+ affine_resolver.wrap_to_subject_ras(affine, use_type, use_pose)
424
+ for affine in affines_scanner
425
+ ]
426
+ result = _finalize_affines(affines_subject_ras, num_slice_packs, decimals)
427
+ return _apply_affine_post_transform(result, kwargs=kwargs)
428
+
429
+
430
+ def _apply_affine_post_transform(affines: AffineReturn, *, kwargs: Mapping[str, Any]) -> AffineReturn:
431
+ """Apply optional flips/rotations to affines right before returning.
432
+
433
+ These transforms are applied in world space and do not depend on output
434
+ `space`. They are controlled via extra kwargs (intentionally not strict):
435
+
436
+ - flip_x / flip_y / flip_z: bool-like
437
+ - rad_x / rad_y / rad_z: radians (float-like)
438
+ """
439
+
440
+ def as_bool(value: Any) -> bool:
441
+ if isinstance(value, str):
442
+ return value.strip().lower() in {"1", "true", "yes", "y", "on"}
443
+ return bool(value)
444
+
445
+ def as_float(value: Any) -> float:
446
+ try:
447
+ return float(value)
448
+ except (TypeError, ValueError):
449
+ return 0.0
450
+
451
+ flip_x = as_bool(kwargs.get("flip_x", False))
452
+ flip_y = as_bool(kwargs.get("flip_y", False))
453
+ flip_z = as_bool(kwargs.get("flip_z", False))
454
+ rad_x = as_float(kwargs.get("rad_x", 0.0))
455
+ rad_y = as_float(kwargs.get("rad_y", 0.0))
456
+ rad_z = as_float(kwargs.get("rad_z", 0.0))
457
+
458
+ if not (flip_x or flip_y or flip_z or rad_x or rad_y or rad_z):
459
+ return affines
460
+
461
+ def apply_one(a: np.ndarray) -> np.ndarray:
462
+ out = np.asarray(a, dtype=float)
463
+ if flip_x or flip_y or flip_z:
464
+ out = affine_resolver.flip_affine(out, flip_x=flip_x, flip_y=flip_y, flip_z=flip_z)
465
+ if rad_x or rad_y or rad_z:
466
+ out = affine_resolver.rotate_affine(out, rad_x=rad_x, rad_y=rad_y, rad_z=rad_z)
467
+ return np.asarray(out, dtype=float)
468
+
469
+ if isinstance(affines, tuple):
470
+ return tuple(apply_one(np.asarray(a)) for a in affines)
471
+ return apply_one(np.asarray(affines))
472
+
473
+
474
+ def get_nifti1image(
475
+ self: Union["Scan", "ScanLoader"],
476
+ reco_id: Optional[int] = None,
477
+ *,
478
+ space: AffineSpace = "subject_ras",
479
+ override_header: Optional[Nifti1HeaderContents] = None,
480
+ override_subject_type: Optional[SubjectType] = None,
481
+ override_subject_pose: Optional[SubjectPose] = None,
482
+ flip_x: bool = False,
483
+ flatten_fg: bool = False,
484
+ xyz_units: XYZUNIT = "mm",
485
+ t_units: TUNIT = "sec",
486
+ hook_args_by_name: Optional[Mapping[str, Mapping[str, Any]]] = None,
487
+ ) -> Optional[Union[Tuple["Nifti1Image", ...], "Nifti1Image"]]:
488
+ """Return NIfTI image(s) for a reco.
489
+
490
+ Args:
491
+ self: Scan or ScanLoader instance.
492
+ reco_id: Reco identifier to read (defaults to the first available).
493
+ space: Output affine space ("raw", "scanner", "subject_ras").
494
+ override_header: Optional header values to apply.
495
+ override_subject_type: Subject type override for subject-view wrapping.
496
+ override_subject_pose: Subject pose override for subject-view wrapping.
497
+ flip_x: If True, set NIfTI header x-flip flag.
498
+ xyz_units: Spatial units for NIfTI header.
499
+ t_units: Temporal units for NIfTI header.
500
+
501
+ Returns:
502
+ Single NIfTI image when one slice pack exists; otherwise a tuple of
503
+ images. Returns None when required metadata is unavailable.
504
+ """
505
+
506
+ if not all(
507
+ hasattr(self, attr) for attr in ["image_info", "affine_info", "get_dataobj", "get_affine"]
508
+ ):
509
+ return None
510
+
511
+ self = cast(ScanLoader, self)
512
+ resolved_reco_id = _resolve_reco_id(self, reco_id)
513
+ if resolved_reco_id is None:
514
+ return None
515
+ hook_kwargs = _resolve_hook_kwargs(self, hook_args_by_name)
516
+ data_kwargs = _filter_hook_kwargs(self.get_dataobj, hook_kwargs)
517
+ if data_kwargs:
518
+ dataobjs = self.get_dataobj(resolved_reco_id, **data_kwargs)
519
+ else:
520
+ dataobjs = self.get_dataobj(resolved_reco_id)
521
+ affine_kwargs = _filter_hook_kwargs(self.get_affine, hook_kwargs)
522
+ if affine_kwargs:
523
+ affines = self.get_affine(
524
+ resolved_reco_id,
525
+ space=space,
526
+ override_subject_type=override_subject_type,
527
+ override_subject_pose=override_subject_pose,
528
+ **affine_kwargs,
529
+ )
530
+ else:
531
+ affines = self.get_affine(
532
+ resolved_reco_id,
533
+ space=space,
534
+ override_subject_type=override_subject_type,
535
+ override_subject_pose=override_subject_pose,
536
+ )
537
+ image_info = self.image_info.get(resolved_reco_id)
538
+
539
+ if dataobjs is None or affines is None or image_info is None:
540
+ return None
541
+
542
+ if not isinstance(dataobjs, tuple) and not isinstance(affines, tuple):
543
+ dataobjs = (dataobjs,)
544
+ affines = (affines,)
545
+
546
+ niiobjs = []
547
+ for i, dataobj in enumerate(dataobjs):
548
+ if flatten_fg and dataobj.ndim > 4:
549
+ spatial_shape = dataobj.shape[:3]
550
+ flattened = int(np.prod(dataobj.shape[3:]))
551
+ dataobj = dataobj.reshape((*spatial_shape, flattened), order="A")
552
+ affine = affines[i]
553
+ niiobj = Nifti1Image(dataobj, affine)
554
+ nifti1header_contents = nifti_resolver.resolve(
555
+ image_info, flip_x=flip_x, xyz_units=xyz_units, t_units=t_units
556
+ )
557
+ if override_header:
558
+ for key, value in override_header.items():
559
+ if value is not None:
560
+ nifti1header_contents[key] = value
561
+ niiobj = nifti_resolver.update(niiobj, nifti1header_contents)
562
+ niiobjs.append(niiobj)
563
+
564
+ if len(niiobjs) == 1:
565
+ return niiobjs[0]
566
+ return tuple(niiobjs)
567
+
568
+
569
+ def convert(
570
+ self: Union["Scan", "ScanLoader"],
571
+ reco_id: Optional[int] = None,
572
+ *,
573
+ format: Literal["nifti", "nifti1"] = "nifti",
574
+ space: AffineSpace = "subject_ras",
575
+ override_header: Optional[Nifti1HeaderContents] = None,
576
+ override_subject_type: Optional[SubjectType] = None,
577
+ override_subject_pose: Optional[SubjectPose] = None,
578
+ flip_x: bool = False,
579
+ flatten_fg: bool = False,
580
+ xyz_units: XYZUNIT = "mm",
581
+ t_units: TUNIT = "sec",
582
+ hook_args_by_name: Optional[Mapping[str, Mapping[str, Any]]] = None,
583
+ ) -> Optional[Union[Tuple["Nifti1Image", ...], "Nifti1Image"]]:
584
+ """Convert a reco to a selected output format."""
585
+ if format not in {"nifti", "nifti1"}:
586
+ raise ValueError(f"Unsupported format: {format}")
587
+ return get_nifti1image(
588
+ self,
589
+ reco_id,
590
+ space=space,
591
+ override_header=override_header,
592
+ override_subject_type=override_subject_type,
593
+ override_subject_pose=override_subject_pose,
594
+ flip_x=flip_x,
595
+ flatten_fg=flatten_fg,
596
+ xyz_units=xyz_units,
597
+ t_units=t_units,
598
+ hook_args_by_name=hook_args_by_name,
599
+ )
600
+
601
+
602
+ def _resolve_hook_kwargs(
603
+ scan: Union["Scan", "ScanLoader"],
604
+ hook_args_by_name: Optional[Mapping[str, Mapping[str, Any]]],
605
+ ) -> Dict[str, Any]:
606
+ if not hook_args_by_name:
607
+ return {}
608
+ hook_name = getattr(scan, "_converter_hook_name", None)
609
+ if not isinstance(hook_name, str) or not hook_name:
610
+ return {}
611
+ values = hook_args_by_name.get(hook_name)
612
+ if values is None:
613
+ seen: set[str] = set()
614
+
615
+ def _add(candidate: str) -> None:
616
+ cand = candidate.strip()
617
+ if not cand or cand in seen:
618
+ return
619
+ seen.add(cand)
620
+
621
+ _add(hook_name)
622
+ _add(hook_name.lower())
623
+ _add(hook_name.replace("_", "-"))
624
+ _add(hook_name.replace("-", "_"))
625
+ _add(hook_name.lower().replace("_", "-"))
626
+ _add(hook_name.lower().replace("-", "_"))
627
+ _add(f"brkraw-{hook_name}")
628
+ _add(f"brkraw_{hook_name}")
629
+ _add(f"brkraw-{hook_name.lower()}")
630
+ _add(f"brkraw_{hook_name.lower()}")
631
+ _add(f"brkraw-{hook_name.lower().replace('_', '-')}")
632
+ _add(f"brkraw_{hook_name.lower().replace('-', '_')}")
633
+
634
+ for candidate in sorted(seen):
635
+ if candidate == hook_name:
636
+ continue
637
+ candidate_values = hook_args_by_name.get(candidate)
638
+ if candidate_values is not None:
639
+ logger.debug(
640
+ "Using hook args for %r from alias %r.",
641
+ hook_name,
642
+ candidate,
643
+ )
644
+ values = candidate_values
645
+ break
646
+ return dict(values) if isinstance(values, Mapping) else {}
647
+
648
+
649
+ def _filter_hook_kwargs(func: Any, hook_kwargs: Mapping[str, Any]) -> Dict[str, Any]:
650
+ """Drop unsupported hook kwargs for a callable.
651
+
652
+ This keeps YAML/CLI presets safe when converter hooks do not accept
653
+ arbitrary kwargs.
654
+ """
655
+ if not hook_kwargs:
656
+ return {}
657
+ try:
658
+ sig = inspect.signature(func)
659
+ except (TypeError, ValueError):
660
+ return dict(hook_kwargs)
661
+ for param in sig.parameters.values():
662
+ if param.kind == inspect.Parameter.VAR_KEYWORD:
663
+ return dict(hook_kwargs)
664
+ allowed = {
665
+ param.name
666
+ for param in sig.parameters.values()
667
+ if param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
668
+ and param.name != "self"
669
+ }
670
+ filtered = {key: value for key, value in hook_kwargs.items() if key in allowed}
671
+ dropped = [key for key in hook_kwargs.keys() if key not in allowed]
672
+ if dropped:
673
+ logger.debug(
674
+ "Ignoring unsupported hook args for %s: %s",
675
+ getattr(func, "__name__", "<callable>"),
676
+ ", ".join(sorted(dropped)),
677
+ )
678
+ return filtered
679
+
680
+
681
+ def _resolve_metadata_spec(
682
+ scan: "ScanLoader",
683
+ spec: Optional[Union[Mapping[str, Any], str, Path]],
684
+ *,
685
+ base: Path,
686
+ ) -> Optional[Tuple[Mapping[str, Any], Dict[str, Any], Optional[Path]]]:
687
+ """Resolve a metadata spec and its transforms for a scan.
688
+
689
+ Args:
690
+ scan: Scan instance to evaluate rules against.
691
+ spec: Optional spec mapping or spec path override.
692
+ base: Config root directory for rule resolution.
693
+
694
+ Returns:
695
+ Tuple of (spec, transforms, spec_path) or None when no spec matches.
696
+ """
697
+ if spec is None:
698
+ try:
699
+ rules = load_rules(root=base, validate=False)
700
+ except Exception:
701
+ return None
702
+ spec_path = select_rule_use(
703
+ scan,
704
+ rules.get("metadata_spec", []),
705
+ base=base,
706
+ resolve_paths=True,
707
+ )
708
+ if not isinstance(spec_path, Path) or not spec_path.exists():
709
+ return None
710
+ spec_data, transforms = load_spec(spec_path, validate=False)
711
+ return spec_data, transforms, spec_path
712
+ if isinstance(spec, (str, Path)):
713
+ spec_path = Path(spec)
714
+ spec_data, transforms = load_spec(spec_path, validate=False)
715
+ return spec_data, transforms, spec_path
716
+ if isinstance(spec, Mapping):
717
+ return spec, {}, None
718
+ raise TypeError(f"Unsupported spec type: {type(spec)!r}")
719
+
720
+
721
+ def get_metadata(
722
+ self,
723
+ reco_id: Optional[int] = None,
724
+ spec: Optional[Union[Mapping[str, Any], str, Path]] = None,
725
+ context_map: Optional[Union[str, Path]] = None,
726
+ return_spec: bool = False,
727
+ ):
728
+ """Resolve metadata using a remapper spec.
729
+
730
+ Args:
731
+ self: Scan instance.
732
+ reco_id: Reco identifier (defaults to the first available).
733
+ spec: Optional spec mapping or spec file path.
734
+ context_map: Optional context map override.
735
+ return_spec: If True, return spec info alongside metadata.
736
+
737
+ Returns:
738
+ Mapping of metadata fields, or None when no spec matches. When
739
+ return_spec is True, returns (metadata, spec_info).
740
+ """
741
+ scan = cast(ScanLoader, self)
742
+ resolved_reco_id = _resolve_reco_id(scan, reco_id)
743
+ if resolved_reco_id is None:
744
+ if return_spec:
745
+ return None, None
746
+ return None
747
+ base = resolve_root(None)
748
+ resolved = _resolve_metadata_spec(scan, spec, base=base)
749
+ if resolved is None:
750
+ if return_spec:
751
+ return None, None
752
+ return None
753
+ spec_data, transforms, spec_path = resolved
754
+ metadata = map_parameters(
755
+ scan,
756
+ spec_data,
757
+ transforms,
758
+ validate=False,
759
+ context_map=None,
760
+ context={"scan_id": getattr(scan, "scan_id", None), "reco_id": resolved_reco_id},
761
+ )
762
+ if context_map:
763
+ map_data = load_context_map(context_map)
764
+ metadata = apply_context_map(
765
+ metadata,
766
+ map_data,
767
+ target="metadata_spec",
768
+ context={"scan_id": getattr(scan, "scan_id", None), "reco_id": resolved_reco_id},
769
+ )
770
+ if not return_spec:
771
+ return metadata
772
+ meta = spec_data.get("__meta__")
773
+ name = meta.get("name") if isinstance(meta, dict) else None
774
+ version = meta.get("version") if isinstance(meta, dict) else None
775
+ spec_info = {"path": spec_path, "name": name, "version": version}
776
+ return metadata, spec_info
777
+
778
+
779
+ def apply_converter_hook(
780
+ scan: "ScanLoader",
781
+ converter_hook: Mapping[str, Any],
782
+ *,
783
+ affine_decimals: Optional[int] = None,
784
+ ) -> None:
785
+ """Override scan conversion helpers using a converter hook."""
786
+ converter_core.validate_hook(converter_hook)
787
+ plugin = dict(converter_hook)
788
+ scan._converter_hook = plugin
789
+ if "get_dataobj" in plugin:
790
+ scan.get_dataobj = MethodType(plugin["get_dataobj"], scan)
791
+ if "get_affine" in plugin:
792
+ get_affine = plugin["get_affine"]
793
+ if affine_decimals is not None:
794
+ get_affine = partial(get_affine, decimals=affine_decimals)
795
+ scan.get_affine = MethodType(get_affine, scan)
796
+ if "convert" in plugin:
797
+ scan.convert = MethodType(plugin["convert"], scan)