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