brkraw 0.5.2__py3-none-any.whl → 0.5.5__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 (39) hide show
  1. brkraw/__init__.py +1 -1
  2. brkraw/api/__init__.py +122 -0
  3. brkraw/api/types.py +39 -0
  4. brkraw/apps/loader/__init__.py +3 -6
  5. brkraw/apps/loader/core.py +128 -132
  6. brkraw/apps/loader/formatter.py +0 -2
  7. brkraw/apps/loader/helper.py +334 -114
  8. brkraw/apps/loader/info/scan.py +2 -2
  9. brkraw/apps/loader/info/transform.py +0 -1
  10. brkraw/apps/loader/types.py +56 -59
  11. brkraw/cli/commands/addon.py +1 -1
  12. brkraw/cli/commands/cache.py +82 -0
  13. brkraw/cli/commands/config.py +2 -2
  14. brkraw/cli/commands/convert.py +61 -38
  15. brkraw/cli/commands/hook.py +1 -3
  16. brkraw/cli/commands/info.py +1 -1
  17. brkraw/cli/commands/init.py +1 -1
  18. brkraw/cli/commands/params.py +1 -1
  19. brkraw/cli/commands/prune.py +2 -2
  20. brkraw/cli/commands/session.py +1 -11
  21. brkraw/cli/main.py +51 -1
  22. brkraw/cli/utils.py +1 -1
  23. brkraw/core/cache.py +87 -0
  24. brkraw/core/config.py +18 -2
  25. brkraw/core/fs.py +26 -9
  26. brkraw/core/zip.py +46 -32
  27. brkraw/dataclasses/__init__.py +3 -2
  28. brkraw/dataclasses/study.py +73 -23
  29. brkraw/resolver/datatype.py +10 -2
  30. brkraw/resolver/image.py +140 -21
  31. brkraw/resolver/nifti.py +4 -12
  32. brkraw/schema/niftiheader.yaml +0 -2
  33. brkraw/specs/meta/validator.py +0 -1
  34. brkraw/specs/rules/logic.py +1 -3
  35. {brkraw-0.5.2.dist-info → brkraw-0.5.5.dist-info}/METADATA +8 -9
  36. {brkraw-0.5.2.dist-info → brkraw-0.5.5.dist-info}/RECORD +39 -35
  37. {brkraw-0.5.2.dist-info → brkraw-0.5.5.dist-info}/entry_points.txt +1 -0
  38. {brkraw-0.5.2.dist-info → brkraw-0.5.5.dist-info}/WHEEL +0 -0
  39. {brkraw-0.5.2.dist-info → brkraw-0.5.5.dist-info}/licenses/LICENSE +0 -0
@@ -1,19 +1,25 @@
1
- """Internal helper functions for BrukerLoader.
2
-
3
- Last updated: 2025-12-30
4
- """
5
-
6
1
  from __future__ import annotations
7
2
 
8
3
  from types import MethodType
9
4
  from functools import partial
10
5
  import inspect
11
- from typing import TYPE_CHECKING, Optional, Tuple, Union, Any, Mapping, cast, List, Dict, Literal
6
+ from typing import (
7
+ TYPE_CHECKING,
8
+ cast,
9
+ Optional,
10
+ Tuple,
11
+ Union,
12
+ Any,
13
+ Mapping,
14
+ List,
15
+ Dict
16
+ )
12
17
  from pathlib import Path
13
18
  from warnings import warn
14
19
  import logging
15
20
 
16
21
  import numpy as np
22
+ from numpy.typing import NDArray
17
23
  from nibabel.nifti1 import Nifti1Image
18
24
 
19
25
  from ...core.config import resolve_root
@@ -21,22 +27,38 @@ from ...core.parameters import Parameters
21
27
  from ...specs.remapper import load_spec, map_parameters, load_context_map, apply_context_map
22
28
  from ...specs.rules import load_rules, select_rule_use
23
29
  from ...dataclasses import Reco, Scan, Study
24
- from .types import ScanLoader
25
30
  from ...specs import hook as converter_core
26
31
  from ...resolver import affine as affine_resolver
27
32
  from ...resolver import image as image_resolver
28
33
  from ...resolver import fid as fid_resolver
29
34
  from ...resolver import nifti as nifti_resolver
30
35
  from ...resolver.helpers import get_file
31
-
36
+ from .types import (
37
+ ScanLoader,
38
+ ConvertType,
39
+ GetDataobjType,
40
+ GetAffineType
41
+ )
32
42
  if TYPE_CHECKING:
33
43
  from ...resolver.nifti import Nifti1HeaderContents
34
- from .types import SubjectType, SubjectPose, XYZUNIT, TUNIT, AffineReturn, AffineSpace
44
+ from .types import (
45
+ SubjectType,
46
+ SubjectPose,
47
+ XYZUNIT,
48
+ TUNIT,
49
+ Dataobjs,
50
+ Affines,
51
+ AffineSpace,
52
+ ConvertedObj,
53
+ Metadata
54
+ )
35
55
 
36
- logger = logging.getLogger("brkraw")
56
+ logger = logging.getLogger(__name__)
37
57
 
38
58
  __all__ = [
59
+ "resolve_reco_id",
39
60
  "resolve_data_and_affine",
61
+ "resolve_converter_hook",
40
62
  "search_parameters",
41
63
  "get_dataobj",
42
64
  "get_affine",
@@ -55,7 +77,7 @@ def make_dir(names: List[str]):
55
77
  return _dir
56
78
 
57
79
 
58
- def _resolve_reco_id(
80
+ def resolve_reco_id(
59
81
  scan: Union["Scan", "ScanLoader"],
60
82
  reco_id: Optional[int],
61
83
  ) -> Optional[int]:
@@ -79,7 +101,7 @@ def _resolve_reco_id(
79
101
 
80
102
 
81
103
  def resolve_data_and_affine(
82
- scan: Union["Scan", "ScanLoader"],
104
+ scan: "Scan",
83
105
  reco_id: Optional[int] = None,
84
106
  *,
85
107
  affine_decimals: int = 6,
@@ -92,6 +114,9 @@ def resolve_data_and_affine(
92
114
  affine_decimals: Decimal rounding applied to resolved affines.
93
115
  """
94
116
  scan = cast(ScanLoader, scan)
117
+ scan.get_fid = MethodType(fid_resolver.resolve, scan)
118
+ scan.image_info = {}
119
+ scan.affine_info = {}
95
120
 
96
121
  reco_ids = [reco_id] if reco_id is not None else list(scan.avail.keys())
97
122
  if not reco_ids:
@@ -108,7 +133,7 @@ def resolve_data_and_affine(
108
133
  )
109
134
  continue
110
135
  try:
111
- image_info = image_resolver.resolve(scan, rid)
136
+ image_info = image_resolver.resolve(scan, rid, load_data=False)
112
137
  except Exception as exc:
113
138
  logger.warning(
114
139
  "Failed to resolve image data for scan %s reco %s: %s",
@@ -122,6 +147,7 @@ def resolve_data_and_affine(
122
147
  affine_info = affine_resolver.resolve(
123
148
  scan, rid, decimals=affine_decimals, unwrap_pose=False,
124
149
  )
150
+
125
151
  except Exception as exc:
126
152
  logger.warning(
127
153
  "Failed to resolve affine for scan %s reco %s: %s",
@@ -131,15 +157,65 @@ def resolve_data_and_affine(
131
157
  )
132
158
  affine_info = None
133
159
 
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
160
+ scan.image_info[rid] = image_info
161
+ scan.affine_info[rid] = affine_info
162
+
163
+ def _load_rules(base):
164
+ try:
165
+ rules = load_rules(root=base, validate=False)
166
+ except Exception:
167
+ rules = {}
168
+ return rules
169
+
170
+
171
+ def resolve_converter_hook(
172
+ scan: "Scan",
173
+ base: Path,
174
+ *,
175
+ affine_decimals: int = 6,
176
+ ):
177
+ scan = cast(ScanLoader, scan)
178
+ rules = _load_rules(base)
179
+ if rules:
180
+ try:
181
+ hook_name = select_rule_use(
182
+ scan,
183
+ rules.get("converter_hook", []),
184
+ base=base,
185
+ resolve_paths=False,
186
+ )
187
+ except Exception as exc:
188
+ logger.debug(
189
+ "Converter hook rule selection failed for scan %s: %s",
190
+ getattr(scan, "scan_id", "?"),
191
+ exc,
192
+ exc_info=True,
193
+ )
194
+ hook_name = None
195
+
196
+ if isinstance(hook_name, str):
197
+ try:
198
+ entry = converter_core.resolve_hook(hook_name)
199
+ except Exception as exc:
200
+ logger.warning(
201
+ "Converter hook %r not available: %s",
202
+ hook_name,
203
+ exc,
204
+ )
205
+ entry = None
206
+ if entry:
207
+ logger.debug("Applying converter hook: %s", hook_name)
208
+ scan._converter_hook_name = hook_name
209
+ apply_converter_hook(
210
+ scan,
211
+ entry,
212
+ affine_decimals=affine_decimals,
213
+ )
214
+ else:
215
+ logger.debug("Converter hook %r resolved to no entry.", hook_name)
140
216
  else:
141
- setattr(scan, "affine_info", {rid: affine_info})
142
- scan.get_fid = MethodType(fid_resolver.resolve, scan)
217
+ logger.debug("No converter hook selected for scan %s.", getattr(scan, "scan_id", "?"))
218
+ scan._hook_resolved = True
143
219
 
144
220
 
145
221
  def search_parameters(
@@ -242,7 +318,7 @@ def search_parameters(
242
318
  if scan_id is None:
243
319
  warn("To search from Study object, specifying <scan_id> is required.")
244
320
  return None
245
- scan = self.get_scan(scan_id)
321
+ scan = cast(ScanLoader, self.get_scan(scan_id))
246
322
  scan_hits = search_node(scan)
247
323
  if reco_id is None:
248
324
  reco_hits = search_recos(scan)
@@ -281,10 +357,10 @@ def search_parameters(
281
357
 
282
358
 
283
359
  def _finalize_affines(
284
- affines: list[np.ndarray],
360
+ affines: List[NDArray],
285
361
  num_slice_packs: int,
286
362
  decimals: Optional[int],
287
- ) -> AffineReturn:
363
+ ) -> Affines:
288
364
  if num_slice_packs == 1:
289
365
  affine = affines[0]
290
366
  if decimals is not None:
@@ -298,38 +374,73 @@ def _finalize_affines(
298
374
 
299
375
 
300
376
  def get_dataobj(
301
- self: Union["Scan", "ScanLoader"],
377
+ self: "ScanLoader",
302
378
  reco_id: Optional[int] = None,
303
- **_: Any,
304
- ) -> Optional[Union[Tuple["np.ndarray", ...], "np.ndarray"]]:
379
+ **kwargs: Dict[str, Any]
380
+ ) -> Dataobjs:
305
381
  """Return reconstructed data for a reco, split by slice pack if needed.
306
382
 
307
383
  Args:
308
384
  self: Scan or ScanLoader instance.
309
- reco_id: Reco identifier to read (defaults to the first available).
385
+ reco_id: Reco identifier to read (defaults to the first available).
386
+ cycle_index: Optional cycle start index (last axis), reads all cycles when None.
387
+ cycle_count: Optional number of cycles to read from cycle_index; reads to end when None.
388
+ Ignored when the dataset reports <= 1 total cycle.
310
389
 
311
390
  Returns:
312
391
  Single ndarray when one slice pack exists; otherwise a tuple of arrays.
313
392
  Returns None when required metadata is unavailable.
314
393
  """
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)
394
+ cycle_index = cast(Optional[int], kwargs.get('cycle_index'))
395
+ cycle_count = cast(Optional[int], kwargs.get('cycle_count'))
396
+ resolved_reco_id = resolve_reco_id(self, reco_id)
319
397
  if resolved_reco_id is None:
320
398
  return None
399
+
321
400
  affine_info = self.affine_info.get(resolved_reco_id)
401
+ if affine_info is None:
402
+ logger.warning(
403
+ "affine_info is not available for scan %s",
404
+ getattr(self, "scan_id", "?")
405
+ )
406
+ return None
322
407
  image_info = self.image_info.get(resolved_reco_id)
323
- if affine_info is None or image_info is None:
408
+ if image_info is None:
409
+ logger.warning(
410
+ "image_info is not available for scan %s",
411
+ getattr(self, "scan_id", "?")
412
+ )
324
413
  return None
325
414
 
326
- num_slices = affine_info["num_slices"]
327
- dataobj = image_info["dataobj"]
415
+ # Normalize cycle arguments if provided.
416
+ cycle_args_requested = cycle_index is not None or cycle_count is not None
417
+ if cycle_index is None and cycle_count is not None:
418
+ cycle_index = 0
419
+
420
+ # If the dataset has <= 1 cycle, ignore cycle slicing to avoid block reads.
421
+ if cycle_args_requested:
422
+ total_cycles = int(image_info["num_cycles"])
423
+ if total_cycles <= 1:
424
+ cycle_index = None
425
+ cycle_count = None
426
+ cycle_args_requested = False
427
+
428
+ if cycle_args_requested or image_info.get("dataobj") is None:
429
+ image_info = image_resolver.resolve(
430
+ self,
431
+ resolved_reco_id,
432
+ load_data=True,
433
+ cycle_index=cycle_index,
434
+ cycle_count=cycle_count,
435
+ )
436
+ self.image_info[resolved_reco_id] = image_info
328
437
 
438
+ num_slices = affine_info["num_slices"]
439
+ dataobj = cast(dict, image_info).get("dataobj")
329
440
  slice_pack = []
330
441
  slice_offset = 0
331
442
  for _num_slices in num_slices:
332
- _dataobj = dataobj[:, :, slice(slice_offset, slice_offset + _num_slices)]
443
+ _dataobj = cast(NDArray, dataobj)[:, :, slice(slice_offset, slice_offset + _num_slices)]
333
444
  slice_offset += _num_slices
334
445
  slice_pack.append(_dataobj)
335
446
 
@@ -339,7 +450,7 @@ def get_dataobj(
339
450
 
340
451
 
341
452
  def get_affine(
342
- self: Union["Scan", "ScanLoader"],
453
+ self: "ScanLoader",
343
454
  reco_id: Optional[int] = None,
344
455
  *,
345
456
  space: AffineSpace = "subject_ras",
@@ -347,7 +458,7 @@ def get_affine(
347
458
  override_subject_pose: Optional["SubjectPose"] = None,
348
459
  decimals: Optional[int] = None,
349
460
  **kwargs: Any,
350
- ) -> AffineReturn:
461
+ ) -> Affines:
351
462
  """
352
463
  Return affine(s) for a reco in the requested coordinate space.
353
464
 
@@ -379,7 +490,7 @@ def get_affine(
379
490
  return None
380
491
 
381
492
  self = cast("ScanLoader", self)
382
- resolved_reco_id = _resolve_reco_id(self, reco_id)
493
+ resolved_reco_id = resolve_reco_id(self, reco_id)
383
494
  if resolved_reco_id is None:
384
495
  return None
385
496
 
@@ -427,7 +538,7 @@ def get_affine(
427
538
  return _apply_affine_post_transform(result, kwargs=kwargs)
428
539
 
429
540
 
430
- def _apply_affine_post_transform(affines: AffineReturn, *, kwargs: Mapping[str, Any]) -> AffineReturn:
541
+ def _apply_affine_post_transform(affines: Affines, *, kwargs: Mapping[str, Any]) -> Affines:
431
542
  """Apply optional flips/rotations to affines right before returning.
432
543
 
433
544
  These transforms are applied in world space and do not depend on output
@@ -458,7 +569,7 @@ def _apply_affine_post_transform(affines: AffineReturn, *, kwargs: Mapping[str,
458
569
  if not (flip_x or flip_y or flip_z or rad_x or rad_y or rad_z):
459
570
  return affines
460
571
 
461
- def apply_one(a: np.ndarray) -> np.ndarray:
572
+ def apply_one(a: NDArray) -> NDArray:
462
573
  out = np.asarray(a, dtype=float)
463
574
  if flip_x or flip_y or flip_z:
464
575
  out = affine_resolver.flip_affine(out, flip_x=flip_x, flip_y=flip_y, flip_z=flip_z)
@@ -473,53 +584,148 @@ def _apply_affine_post_transform(affines: AffineReturn, *, kwargs: Mapping[str,
473
584
 
474
585
  def get_nifti1image(
475
586
  self: Union["Scan", "ScanLoader"],
587
+ reco_id: int,
588
+ dataobjs: Tuple[NDArray, ...],
589
+ affines: Tuple[NDArray, ...],
590
+ *,
591
+ xyz_units: XYZUNIT = "mm",
592
+ t_units: TUNIT = "sec",
593
+ override_header: Optional[Nifti1HeaderContents] = None,
594
+ ) -> ConvertedObj:
595
+ """Return NIfTI image(s) for a reco.
596
+
597
+ Args:
598
+ self: Scan or ScanLoader instance.
599
+ reco_id: Reco identifier to read (defaults to the first available).
600
+ xyz_units: Spatial units for NIfTI header.
601
+ t_units: Temporal units for NIfTI header.
602
+ override_header: Optional header values to apply.
603
+
604
+ Returns:
605
+ Output object(s) supporting to_filename(). Returns None when required
606
+ metadata is unavailable.
607
+ """
608
+ self = cast(ScanLoader, self)
609
+
610
+ image_info = self.image_info.get(reco_id)
611
+ if image_info is None:
612
+ return None
613
+
614
+ if dataobjs is None or affines is None:
615
+ return None
616
+
617
+ niiobjs = []
618
+ for i, dataobj in enumerate(dataobjs):
619
+ affine = affines[i]
620
+ niiobj = Nifti1Image(dataobj, affine)
621
+ nifti1header_contents = nifti_resolver.resolve(
622
+ image_info, xyz_units=xyz_units, t_units=t_units
623
+ )
624
+ if override_header:
625
+ for key, value in override_header.items():
626
+ if value is not None:
627
+ nifti1header_contents[key] = value
628
+ niiobj = nifti_resolver.update(niiobj, nifti1header_contents)
629
+ niiobjs.append(niiobj)
630
+
631
+ if len(niiobjs) == 1:
632
+ return niiobjs[0]
633
+ return tuple(niiobjs)
634
+
635
+
636
+ def convert(
637
+ self: "ScanLoader",
476
638
  reco_id: Optional[int] = None,
477
639
  *,
478
640
  space: AffineSpace = "subject_ras",
479
641
  override_header: Optional[Nifti1HeaderContents] = None,
480
642
  override_subject_type: Optional[SubjectType] = None,
481
643
  override_subject_pose: Optional[SubjectPose] = None,
482
- flip_x: bool = False,
483
644
  flatten_fg: bool = False,
484
- xyz_units: XYZUNIT = "mm",
485
- t_units: TUNIT = "sec",
486
645
  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
-
646
+ **kwargs: Any,
647
+ ) -> ConvertedObj:
648
+ """Convert a reco to output object(s).
649
+
490
650
  Args:
491
- self: Scan or ScanLoader instance.
492
- reco_id: Reco identifier to read (defaults to the first available).
493
651
  space: Output affine space ("raw", "scanner", "subject_ras").
494
652
  override_header: Optional header values to apply.
495
653
  override_subject_type: Subject type override for subject-view wrapping.
496
654
  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
-
655
+ flatten_fg: If True, flatten foreground dimensions.
656
+ hook_args_by_name: Optional hook args mapping (split per helper signature).
657
+ flatten_fg: If True, flatten foreground dimensions.
501
658
  Returns:
502
659
  Single NIfTI image when one slice pack exists; otherwise a tuple of
503
660
  images. Returns None when required metadata is unavailable.
504
661
  """
505
-
506
662
  if not all(
507
663
  hasattr(self, attr) for attr in ["image_info", "affine_info", "get_dataobj", "get_affine"]
508
664
  ):
509
665
  return None
510
-
666
+
511
667
  self = cast(ScanLoader, self)
512
- resolved_reco_id = _resolve_reco_id(self, reco_id)
668
+ resolved_reco_id = resolve_reco_id(self, reco_id)
669
+ logger.debug("Resolved reco_id = %s", resolved_reco_id)
513
670
  if resolved_reco_id is None:
514
671
  return None
672
+
673
+ hook_name = getattr(self, "_converter_hook_name", None)
674
+ if isinstance(hook_name, str) and hook_name:
675
+ logger.debug(
676
+ "Convert starting for scan %s reco %s with hook %s",
677
+ getattr(self, "scan_id", "?"),
678
+ resolved_reco_id,
679
+ hook_name,
680
+ )
681
+ else:
682
+ logger.debug(
683
+ "Convert starting for scan %s reco %s (no hook)",
684
+ getattr(self, "scan_id", "?"),
685
+ resolved_reco_id,
686
+ )
687
+
515
688
  hook_kwargs = _resolve_hook_kwargs(self, hook_args_by_name)
516
- data_kwargs = _filter_hook_kwargs(self.get_dataobj, hook_kwargs)
689
+
690
+ # Merge explicit **kwargs (CLI/user) with hook kwargs. Explicit kwargs win.
691
+ merged_kwargs: Dict[str, Any] = dict(hook_kwargs) if hook_kwargs else {}
692
+ merged_kwargs.update(kwargs)
693
+
694
+ data_kwargs = _filter_hook_kwargs(self.get_dataobj, merged_kwargs)
695
+ # flip_* are affine-only options; never pass them to get_dataobj.
696
+ for key in ("flip_x", "flip_y", "flip_z"):
697
+ data_kwargs.pop(key, None)
698
+
699
+ convert_kwargs = {key: value for key, value in merged_kwargs.items() if key not in data_kwargs}
517
700
  if data_kwargs:
701
+ logger.debug(
702
+ "Calling get_dataobj for scan %s reco %s with args %s",
703
+ getattr(self, "scan_id", "?"),
704
+ resolved_reco_id,
705
+ data_kwargs,
706
+ )
518
707
  dataobjs = self.get_dataobj(resolved_reco_id, **data_kwargs)
519
708
  else:
709
+ logger.debug(
710
+ "Calling get_dataobj for scan %s reco %s (no args)",
711
+ getattr(self, "scan_id", "?"),
712
+ resolved_reco_id,
713
+ )
520
714
  dataobjs = self.get_dataobj(resolved_reco_id)
521
- affine_kwargs = _filter_hook_kwargs(self.get_affine, hook_kwargs)
715
+
716
+ affine_kwargs = _filter_hook_kwargs(self.get_affine, merged_kwargs)
717
+ convert_kwargs = {
718
+ key: value
719
+ for key, value in convert_kwargs.items()
720
+ if key not in affine_kwargs
721
+ }
522
722
  if affine_kwargs:
723
+ logger.debug(
724
+ "Calling get_affine for scan %s reco %s with args %s",
725
+ getattr(self, "scan_id", "?"),
726
+ resolved_reco_id,
727
+ affine_kwargs,
728
+ )
523
729
  affines = self.get_affine(
524
730
  resolved_reco_id,
525
731
  space=space,
@@ -528,77 +734,62 @@ def get_nifti1image(
528
734
  **affine_kwargs,
529
735
  )
530
736
  else:
737
+ logger.debug(
738
+ "Calling get_affine for scan %s reco %s (no args)",
739
+ getattr(self, "scan_id", "?"),
740
+ resolved_reco_id,
741
+ )
531
742
  affines = self.get_affine(
532
743
  resolved_reco_id,
533
744
  space=space,
534
745
  override_subject_type=override_subject_type,
535
746
  override_subject_pose=override_subject_pose,
536
747
  )
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:
748
+
749
+ if dataobjs is None or affines is None:
540
750
  return None
541
-
542
- if not isinstance(dataobjs, tuple) and not isinstance(affines, tuple):
751
+
752
+ if not isinstance(dataobjs, tuple):
543
753
  dataobjs = (dataobjs,)
754
+ if not isinstance(affines, tuple):
544
755
  affines = (affines,)
545
-
546
- niiobjs = []
756
+
757
+ dataobjs = list(dataobjs)
547
758
  for i, dataobj in enumerate(dataobjs):
548
759
  if flatten_fg and dataobj.ndim > 4:
549
760
  spatial_shape = dataobj.shape[:3]
550
761
  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)
762
+ dataobjs[i] = dataobj.reshape((*spatial_shape, flattened), order="A")
763
+ dataobjs = tuple(dataobjs)
567
764
 
765
+ converter_func = getattr(self, "converter_func", None)
766
+ if isinstance(converter_func, ConvertType):
767
+ hook_call_kwargs = _filter_hook_kwargs(converter_func, convert_kwargs)
768
+ logger.debug(
769
+ "Calling converter hook for scan %s reco %s with args %s",
770
+ getattr(self, "scan_id", "?"),
771
+ resolved_reco_id,
772
+ hook_call_kwargs,
773
+ )
774
+ return converter_func(
775
+ dataobj=dataobjs,
776
+ affine=affines,
777
+ **hook_call_kwargs,
778
+ )
568
779
 
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}")
780
+ nifti1image_kwargs = {
781
+ "override_header": override_header,
782
+ **kwargs,
783
+ }
784
+ nifti1image_kwargs = _filter_hook_kwargs(get_nifti1image, nifti1image_kwargs)
587
785
  return get_nifti1image(
588
786
  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,
787
+ reco_id=resolved_reco_id,
788
+ dataobjs=dataobjs,
789
+ affines=affines,
790
+ **nifti1image_kwargs,
599
791
  )
600
792
 
601
-
602
793
  def _resolve_hook_kwargs(
603
794
  scan: Union["Scan", "ScanLoader"],
604
795
  hook_args_by_name: Optional[Mapping[str, Mapping[str, Any]]],
@@ -608,6 +799,12 @@ def _resolve_hook_kwargs(
608
799
  hook_name = getattr(scan, "_converter_hook_name", None)
609
800
  if not isinstance(hook_name, str) or not hook_name:
610
801
  return {}
802
+ logger.debug(
803
+ "Resolving hook args for scan %s hook %s (available: %s)",
804
+ getattr(scan, "scan_id", "?"),
805
+ hook_name,
806
+ sorted(hook_args_by_name.keys()),
807
+ )
611
808
  values = hook_args_by_name.get(hook_name)
612
809
  if values is None:
613
810
  seen: set[str] = set()
@@ -643,7 +840,12 @@ def _resolve_hook_kwargs(
643
840
  )
644
841
  values = candidate_values
645
842
  break
646
- return dict(values) if isinstance(values, Mapping) else {}
843
+ resolved = dict(values) if isinstance(values, Mapping) else {}
844
+ if resolved:
845
+ logger.debug("Resolved hook args for %s: %s", hook_name, resolved)
846
+ else:
847
+ logger.debug("No hook args resolved for %s.", hook_name)
848
+ return resolved
647
849
 
648
850
 
649
851
  def _filter_hook_kwargs(func: Any, hook_kwargs: Mapping[str, Any]) -> Dict[str, Any]:
@@ -724,7 +926,7 @@ def get_metadata(
724
926
  spec: Optional[Union[Mapping[str, Any], str, Path]] = None,
725
927
  context_map: Optional[Union[str, Path]] = None,
726
928
  return_spec: bool = False,
727
- ):
929
+ ) -> Metadata:
728
930
  """Resolve metadata using a remapper spec.
729
931
 
730
932
  Args:
@@ -739,7 +941,7 @@ def get_metadata(
739
941
  return_spec is True, returns (metadata, spec_info).
740
942
  """
741
943
  scan = cast(ScanLoader, self)
742
- resolved_reco_id = _resolve_reco_id(scan, reco_id)
944
+ resolved_reco_id = resolve_reco_id(scan, reco_id)
743
945
  if resolved_reco_id is None:
744
946
  if return_spec:
745
947
  return None, None
@@ -785,6 +987,17 @@ def apply_converter_hook(
785
987
  """Override scan conversion helpers using a converter hook."""
786
988
  converter_core.validate_hook(converter_hook)
787
989
  plugin = dict(converter_hook)
990
+ logger.debug(
991
+ "Binding converter hook for scan %s: %s",
992
+ getattr(scan, "scan_id", "?"),
993
+ sorted(plugin.keys()),
994
+ )
995
+ if "get_dataobj" in plugin and not isinstance(plugin["get_dataobj"], GetDataobjType):
996
+ raise TypeError("Converter hook 'get_dataobj' must match GetDataobjType.")
997
+ if "get_affine" in plugin and not isinstance(plugin["get_affine"], GetAffineType):
998
+ raise TypeError("Converter hook 'get_affine' must match GetAffineType.")
999
+ if "convert" in plugin and not isinstance(plugin["convert"], ConvertType):
1000
+ raise TypeError("Converter hook 'convert' must match ConvertType.")
788
1001
  scan._converter_hook = plugin
789
1002
  if "get_dataobj" in plugin:
790
1003
  scan.get_dataobj = MethodType(plugin["get_dataobj"], scan)
@@ -794,4 +1007,11 @@ def apply_converter_hook(
794
1007
  get_affine = partial(get_affine, decimals=affine_decimals)
795
1008
  scan.get_affine = MethodType(get_affine, scan)
796
1009
  if "convert" in plugin:
797
- scan.convert = MethodType(plugin["convert"], scan)
1010
+ scan.converter_func = MethodType(plugin["convert"], scan)
1011
+ else:
1012
+ scan.converter_func = None
1013
+ logger.debug(
1014
+ "Converter hook bound for scan %s (hook=%s)",
1015
+ getattr(scan, "scan_id", "?"),
1016
+ getattr(scan, "_converter_hook_name", None),
1017
+ )