brkraw 0.5.0rc1__py3-none-any.whl → 0.5.1__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 (36) hide show
  1. brkraw/__init__.py +1 -1
  2. brkraw/apps/hook/core.py +58 -10
  3. brkraw/apps/loader/core.py +5 -1
  4. brkraw/apps/loader/helper.py +155 -14
  5. brkraw/apps/loader/info/scan.py +18 -5
  6. brkraw/apps/loader/types.py +6 -1
  7. brkraw/cli/commands/convert.py +201 -79
  8. brkraw/cli/commands/hook.py +146 -4
  9. brkraw/cli/commands/session.py +6 -1
  10. brkraw/cli/hook_args.py +80 -0
  11. brkraw/core/config.py +8 -0
  12. brkraw/core/layout.py +56 -11
  13. brkraw/default/rules/00_default.yaml +4 -0
  14. brkraw/default/specs/metadata_dicom.yaml +236 -0
  15. brkraw/default/specs/metadata_transforms.py +18 -0
  16. brkraw/resolver/affine.py +56 -32
  17. brkraw/schema/context_map.yaml +5 -0
  18. brkraw/schema/remapper.yaml +6 -0
  19. brkraw/specs/__init__.py +2 -2
  20. brkraw/specs/{converter → hook}/logic.py +1 -0
  21. brkraw/specs/{converter → hook}/validator.py +1 -0
  22. brkraw/specs/remapper/logic.py +83 -16
  23. brkraw/specs/remapper/validator.py +21 -5
  24. {brkraw-0.5.0rc1.dist-info → brkraw-0.5.1.dist-info}/METADATA +31 -4
  25. {brkraw-0.5.0rc1.dist-info → brkraw-0.5.1.dist-info}/RECORD +29 -33
  26. brkraw/default/rules/10-metadata.yaml +0 -42
  27. brkraw/default/rules/20-mrs.yaml +0 -14
  28. brkraw/default/specs/metadata_anat.yaml +0 -54
  29. brkraw/default/specs/metadata_common.yaml +0 -129
  30. brkraw/default/specs/metadata_func.yaml +0 -127
  31. brkraw/default/specs/mrs.yaml +0 -71
  32. brkraw/default/specs/mrs_transforms.py +0 -26
  33. brkraw/specs/{converter → hook}/__init__.py +1 -1
  34. {brkraw-0.5.0rc1.dist-info → brkraw-0.5.1.dist-info}/WHEEL +0 -0
  35. {brkraw-0.5.0rc1.dist-info → brkraw-0.5.1.dist-info}/entry_points.txt +0 -0
  36. {brkraw-0.5.0rc1.dist-info → brkraw-0.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Mapping
6
+
7
+ import yaml
8
+
9
+
10
+ def merge_hook_args(
11
+ base: Mapping[str, Mapping[str, Any]],
12
+ override: Mapping[str, Mapping[str, Any]],
13
+ ) -> Dict[str, Dict[str, Any]]:
14
+ merged: Dict[str, Dict[str, Any]] = {}
15
+ for hook_name, values in base.items():
16
+ if not isinstance(hook_name, str) or not hook_name:
17
+ continue
18
+ if not isinstance(values, Mapping):
19
+ continue
20
+ merged[hook_name] = dict(values)
21
+ for hook_name, values in override.items():
22
+ if not isinstance(hook_name, str) or not hook_name:
23
+ continue
24
+ if not isinstance(values, Mapping):
25
+ continue
26
+ merged.setdefault(hook_name, {}).update(dict(values))
27
+ return merged
28
+
29
+
30
+ def load_hook_args_yaml(paths: List[str]) -> Dict[str, Dict[str, Any]]:
31
+ """Load hook args mapping from YAML files.
32
+
33
+ Supported YAML formats:
34
+ - `{hooks: {hook_name: {key: value}}}`
35
+ - `{hook_name: {key: value}}`
36
+ """
37
+
38
+ merged: Dict[str, Dict[str, Any]] = {}
39
+
40
+ def normalize_doc(doc: Any, *, source: str) -> Dict[str, Dict[str, Any]]:
41
+ if doc is None:
42
+ return {}
43
+ hooks_obj = doc.get("hooks") if isinstance(doc, Mapping) else None
44
+ if hooks_obj is None and isinstance(doc, Mapping):
45
+ hooks_obj = doc
46
+ if not isinstance(hooks_obj, Mapping):
47
+ raise ValueError(f"Invalid hook args YAML in {source!r}: expected mapping.")
48
+
49
+ out: Dict[str, Dict[str, Any]] = {}
50
+ for hook_name, values in hooks_obj.items():
51
+ if not isinstance(hook_name, str) or not hook_name.strip():
52
+ continue
53
+ if values is None:
54
+ continue
55
+ if not isinstance(values, Mapping):
56
+ raise ValueError(
57
+ f"Invalid hook args YAML in {source!r}: hook {hook_name!r} must map to a dict."
58
+ )
59
+ out[hook_name.strip()] = dict(values)
60
+ return out
61
+
62
+ for raw in paths:
63
+ source = raw.strip()
64
+ if not source:
65
+ continue
66
+ if source == "-":
67
+ doc = yaml.safe_load(sys.stdin.read())
68
+ else:
69
+ path = Path(source).expanduser()
70
+ if not path.exists():
71
+ raise ValueError(f"Hook args YAML not found: {source}")
72
+ doc = yaml.safe_load(path.read_text(encoding="utf-8"))
73
+ parsed = normalize_doc(doc, source=source)
74
+ merged = merge_hook_args(merged, parsed)
75
+
76
+ return merged
77
+
78
+
79
+ __all__ = ["load_hook_args_yaml", "merge_hook_args"]
80
+
brkraw/core/config.py CHANGED
@@ -42,6 +42,14 @@ output:
42
42
  slicepack_suffix: "_slpack{index}"
43
43
  # float_decimals: 6
44
44
 
45
+ # Viewer settings for brkraw-viewer (optional GUI extension).
46
+ viewer:
47
+ cache:
48
+ # Cache loaded scan data in memory to speed up space/pose changes.
49
+ enabled: true
50
+ # Maximum number of scan/reco entries to keep (LRU). 0 disables caching.
51
+ max_items: 10
52
+
45
53
  # rules_dir: rules
46
54
  # specs_dir: specs
47
55
  # pruner_specs_dir: pruner_specs
brkraw/core/layout.py CHANGED
@@ -31,6 +31,7 @@ def render_layout(
31
31
  context_map: Optional[Union[str, Path]] = None,
32
32
  root: Optional[Union[str, Path]] = None,
33
33
  reco_id: Optional[int] = None,
34
+ counter: Optional[int] = None,
34
35
  override_info_spec: Optional[Union[str, Path]] = None,
35
36
  override_metadata_spec: Optional[Union[str, Path]] = None,
36
37
  ) -> str:
@@ -50,8 +51,8 @@ def render_layout(
50
51
  override_metadata_spec=override_metadata_spec,
51
52
  )
52
53
  if isinstance(layout_template, str) and layout_template:
53
- return _render_layout_template(layout_template, info, scan_id)
54
- return _render_fields(layout_entries, info, scan_id)
54
+ return _render_layout_template(layout_template, info, scan_id, reco_id=reco_id, counter=counter)
55
+ return _render_fields(layout_entries, info, scan_id, reco_id=reco_id, counter=counter)
55
56
 
56
57
 
57
58
  def load_layout_info(
@@ -111,6 +112,12 @@ def load_layout_info_parts(
111
112
  if not isinstance(mapped, dict):
112
113
  raise ValueError("override_info_spec must resolve to a mapping.")
113
114
  info = mapped
115
+ study_info = info_resolver.study(loader) or {}
116
+ if isinstance(study_info, dict):
117
+ if "Study" in study_info and "Study" not in info:
118
+ info["Study"] = study_info["Study"]
119
+ if "Subject" in study_info and "Subject" not in info:
120
+ info["Subject"] = study_info["Subject"]
114
121
  else:
115
122
  study_info = info_resolver.study(loader) or {}
116
123
  scan_info = info_resolver.scan(scan) or {}
@@ -152,19 +159,26 @@ def render_slicepack_suffixes(
152
159
  *,
153
160
  count: int,
154
161
  template: str = "_slpack{index}",
162
+ counter: Optional[int] = None,
155
163
  ) -> List[str]:
156
164
  suffixes: List[str] = []
157
165
  for idx in range(count):
158
- suffixes.append(_render_slicepack_suffix(template, info, idx))
166
+ suffixes.append(_render_slicepack_suffix(template, info, idx, counter=counter))
159
167
  return suffixes
160
168
 
161
169
 
162
- def _render_slicepack_suffix(template: str, info: Mapping[str, Any], idx: int) -> str:
170
+ def _render_slicepack_suffix(
171
+ template: str,
172
+ info: Mapping[str, Any],
173
+ idx: int,
174
+ *,
175
+ counter: Optional[int],
176
+ ) -> str:
163
177
  def _replace(match: re.Match[str]) -> str:
164
178
  tag = match.group(1)
165
179
  if tag.lower() == "index":
166
180
  return str(idx + 1)
167
- value = _resolve_tag(tag, info, idx + 1)
181
+ value = _resolve_tag(tag, info, idx + 1, reco_id=None, counter=counter)
168
182
  chosen = _select_indexed_value(value, idx)
169
183
  if chosen is None:
170
184
  return str(idx + 1)
@@ -214,6 +228,9 @@ def _render_fields(
214
228
  fields: Optional[Iterable[Mapping[str, Any]]],
215
229
  info: Mapping[str, Any],
216
230
  scan_id: int,
231
+ *,
232
+ reco_id: Optional[int],
233
+ counter: Optional[int],
217
234
  ) -> str:
218
235
  parts: List[str] = []
219
236
  seps: List[Optional[str]] = []
@@ -250,7 +267,7 @@ def _render_fields(
250
267
  entry_clean = key.replace(".", "").lower()
251
268
  if not _ENTRY_PATTERN.match(entry_clean):
252
269
  continue
253
- value = _resolve_tag(key, info, scan_id)
270
+ value = _resolve_tag(key, info, scan_id, reco_id=reco_id, counter=counter)
254
271
  value_str = _format_value_with_options(
255
272
  value,
256
273
  value_pattern=value_pattern,
@@ -297,25 +314,53 @@ def _render_fields(
297
314
  return result
298
315
 
299
316
 
300
- def _render_layout_template(template: str, info: Mapping[str, Any], scan_id: int) -> str:
317
+ def _render_layout_template(
318
+ template: str,
319
+ info: Mapping[str, Any],
320
+ scan_id: int,
321
+ *,
322
+ reco_id: Optional[int],
323
+ counter: Optional[int],
324
+ ) -> str:
301
325
  if not _LAYOUT_TAG.search(template):
302
326
  return template
303
- rendered = _LAYOUT_TAG.sub(lambda m: _resolve_layout_tag(m, info, scan_id), template)
327
+ rendered = _LAYOUT_TAG.sub(
328
+ lambda m: _resolve_layout_tag(m, info, scan_id, reco_id=reco_id, counter=counter),
329
+ template,
330
+ )
304
331
  return rendered or template
305
332
 
306
333
 
307
- def _resolve_layout_tag(match: re.Match[str], info: Mapping[str, Any], scan_id: int) -> str:
334
+ def _resolve_layout_tag(
335
+ match: re.Match[str],
336
+ info: Mapping[str, Any],
337
+ scan_id: int,
338
+ *,
339
+ reco_id: Optional[int],
340
+ counter: Optional[int],
341
+ ) -> str:
308
342
  tag = match.group(1) or ""
309
343
  if not tag:
310
344
  return ""
311
- value = _resolve_tag(tag.strip(), info, scan_id)
345
+ value = _resolve_tag(tag.strip(), info, scan_id, reco_id=reco_id, counter=counter)
312
346
  rendered = _format_value(value)
313
347
  return rendered or ""
314
348
 
315
349
 
316
- def _resolve_tag(tag: str, info: Mapping[str, Any], scan_id: int) -> Any:
350
+ def _resolve_tag(
351
+ tag: str,
352
+ info: Mapping[str, Any],
353
+ scan_id: int,
354
+ *,
355
+ reco_id: Optional[int] = None,
356
+ counter: Optional[int] = None,
357
+ ) -> Any:
317
358
  if tag in {"ScanID", "scan_id", "scanid"}:
318
359
  return scan_id
360
+ if tag in {"RecoID", "reco_id", "recoid"}:
361
+ return reco_id
362
+ if tag in {"Counter", "counter"}:
363
+ return counter
319
364
  if "." in tag:
320
365
  root_key, rest = tag.split(".", 1)
321
366
  root_val = info.get(root_key)
@@ -0,0 +1,4 @@
1
+ metadata_spec:
2
+ - name: "metadata-dicom"
3
+ description: "Default metadata mapping for Bruker scans."
4
+ use: "specs/metadata_dicom.yaml"
@@ -0,0 +1,236 @@
1
+ __meta__:
2
+ name: "metadata_dicom"
3
+ version: "0.0.1"
4
+ description: "DICOM metadata mapping for Bruker PvDataset."
5
+ category: "metadata_spec"
6
+ transforms_source: "metadata_transforms.py"
7
+
8
+ ImageType:
9
+ sources:
10
+ - file: visu_pars
11
+ key: VisuSeriesTypeId
12
+ transform: strip_jcamp_string
13
+
14
+ AcquisitionDateTime:
15
+ sources:
16
+ - file: visu_pars
17
+ key: VisuAcqDate
18
+ transform: strip_jcamp_string
19
+
20
+ MagneticFieldStrength:
21
+ sources:
22
+ - file: visu_pars
23
+ key: VisuMagneticFieldStrength
24
+
25
+ ScanningSequence:
26
+ sources:
27
+ - file: visu_pars
28
+ key: VisuAcqSequenceName
29
+ transform: strip_jcamp_string
30
+
31
+ SequenceVariant:
32
+ sources:
33
+ - file: visu_pars
34
+ key: VisuAcqSequenceName
35
+ transform: strip_jcamp_string
36
+
37
+ ScanOptions:
38
+ sources:
39
+ - file: visu_pars
40
+ key: VisuAcqSpectralSuppression
41
+ transform: strip_jcamp_string
42
+
43
+ RepetitionTime:
44
+ sources:
45
+ - file: visu_pars
46
+ key: VisuAcqRepetitionTime
47
+
48
+ EchoTime:
49
+ sources:
50
+ - file: visu_pars
51
+ key: VisuAcqEchoTime
52
+
53
+ InversionTime:
54
+ sources:
55
+ - file: visu_pars
56
+ key: VisuAcqInversionTime
57
+
58
+ FlipAngle:
59
+ sources:
60
+ - file: visu_pars
61
+ key: VisuAcqFlipAngle
62
+
63
+ SliceThickness:
64
+ sources:
65
+ - file: visu_pars
66
+ key: VisuCoreSliceThickness
67
+
68
+ PixelBandwidth:
69
+ sources:
70
+ - file: visu_pars
71
+ key: VisuAcqPixelBandwidth
72
+
73
+ InPlanePhaseEncodingDirection:
74
+ sources:
75
+ - file: visu_pars
76
+ key: VisuAcqGradEncoding
77
+
78
+ PercentSampling:
79
+ sources:
80
+ - file: visu_pars
81
+ key: VisuAcqPartialFourier
82
+
83
+ PercentPhaseFieldOfView:
84
+ sources:
85
+ - file: visu_pars
86
+ key: VisuCoreExtent
87
+
88
+ PixelSpacing:
89
+ inputs:
90
+ extent:
91
+ sources:
92
+ - file: visu_pars
93
+ key: VisuCoreExtent
94
+ size:
95
+ sources:
96
+ - file: visu_pars
97
+ key: VisuCoreSize
98
+ transform: pixel_spacing_from_extent
99
+
100
+ SliceThickness_FG:
101
+ sources:
102
+ - file: visu_pars
103
+ key: VisuCoreSliceThickness
104
+
105
+ ImagePositionPatient:
106
+ sources:
107
+ - file: visu_pars
108
+ key: VisuCorePosition
109
+ transform: as_list
110
+
111
+ ImageOrientationPatient:
112
+ sources:
113
+ - file: visu_pars
114
+ key: VisuCoreOrientation
115
+ transform: as_list
116
+
117
+ RescaleSlope_FG:
118
+ sources:
119
+ - file: visu_pars
120
+ key: VisuCoreDataSlope
121
+
122
+ RescaleIntercept_FG:
123
+ sources:
124
+ - file: visu_pars
125
+ key: VisuCoreDataOffs
126
+
127
+ RepetitionTime_FG:
128
+ sources:
129
+ - file: visu_pars
130
+ key: VisuAcqRepetitionTime
131
+
132
+ EchoTime_FG:
133
+ sources:
134
+ - file: visu_pars
135
+ key: VisuAcqEchoTime
136
+
137
+ FlipAngle_FG:
138
+ sources:
139
+ - file: visu_pars
140
+ key: VisuAcqFlipAngle
141
+
142
+ EchoTrainLength:
143
+ sources:
144
+ - file: visu_pars
145
+ key: VisuAcqEchoTrainLength
146
+
147
+ NumberOfAverages:
148
+ sources:
149
+ - file: visu_pars
150
+ key: VisuAcqNAverages
151
+
152
+ PartialFourier:
153
+ sources:
154
+ - file: visu_pars
155
+ key: VisuAcqPartialFourier
156
+
157
+ PhaseEncodingDirectionPositive:
158
+ sources:
159
+ - file: visu_pars
160
+ key: VisuAcqGradEncoding
161
+
162
+ DiffusionBValue_FG:
163
+ sources:
164
+ - file: visu_pars
165
+ key: VisuAcqDiffusionBMatrix
166
+
167
+ DiffusionGradientOrientation_FG:
168
+ sources:
169
+ - file: visu_pars
170
+ key: VisuAcqDiffusionGradOrient
171
+ transform: as_list
172
+
173
+ MRSpectroscopyAcquisitionType:
174
+ sources:
175
+ - file: visu_pars
176
+ key: VisuMrsAcquisitionType
177
+ transform: strip_jcamp_string
178
+
179
+ ResonantNucleus:
180
+ sources:
181
+ - file: visu_pars
182
+ key: VisuMrsResonantNuclei
183
+ transform: strip_jcamp_string
184
+
185
+ SpectralWidth_MRS:
186
+ sources:
187
+ - file: visu_pars
188
+ key: VisuMrsSpectralWidth
189
+
190
+ TransmitterFrequency_MRS:
191
+ sources:
192
+ - file: visu_pars
193
+ key: VisuMrsTransmitterFreq
194
+
195
+ ChemicalShiftReference:
196
+ sources:
197
+ - file: visu_pars
198
+ key: VisuMrsChemicalShiftRef
199
+
200
+ NumberOfZeroFills:
201
+ sources:
202
+ - file: visu_pars
203
+ key: VisuMrsZeroFill
204
+
205
+ KSpaceFiltering:
206
+ sources:
207
+ - file: visu_pars
208
+ key: VisuAcqKSpaceFiltering
209
+
210
+ PulseSequenceName_MRS:
211
+ sources:
212
+ - file: visu_pars
213
+ key: VisuAcqSequenceName
214
+ transform: strip_jcamp_string
215
+
216
+ EchoTime_MRS:
217
+ sources:
218
+ - file: visu_pars
219
+ key: VisuAcqEchoTime
220
+
221
+ RepetitionTime_MRS:
222
+ sources:
223
+ - file: visu_pars
224
+ key: VisuAcqRepetitionTime
225
+
226
+ WaterSuppression:
227
+ sources:
228
+ - file: visu_pars
229
+ key: VisuAcqSpectralSuppression
230
+ transform: strip_jcamp_string
231
+
232
+ VolumeLocalizationTechnique:
233
+ sources:
234
+ - file: visu_pars
235
+ key: VisuMrsLocalizationTechnique
236
+ transform: strip_jcamp_string
@@ -51,6 +51,24 @@ def as_list(value):
51
51
  return [value]
52
52
 
53
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
+
54
72
  def normalize_method(value: Optional[str]) -> str:
55
73
  return strip_jcamp_string(value).upper()
56
74
 
brkraw/resolver/affine.py CHANGED
@@ -328,42 +328,66 @@ def resolve_matvec_and_shape(visu_pars,
328
328
  shape = np.asarray(visu_pars.get("VisuCoreSize"), dtype=float)
329
329
 
330
330
  if dim == 2:
331
- num_rotates = rotate.shape[0]
332
- num_origins = origin.shape[0]
333
331
  num_slicepack = len(num_slices)
334
- if num_rotates != num_origins:
335
- raise ValueError("num_rotates != num_origins")
336
-
337
- expected = int(num_slicepack * num_slices[spack_idx])
338
- if rotate.ndim == 2 and rotate.shape[1] == 9 and rotate.shape[0] > expected:
339
- if not np.allclose(rotate, rotate[0], atol=0, rtol=0):
340
- logger.warning(
341
- "VisuCoreOrientation has %s entries but expected %s; "
342
- "using the first %s entry/entries.",
343
- rotate.shape[0],
344
- expected,
345
- expected,
346
- )
347
- if origin.ndim == 2 and origin.shape[1] == 3 and origin.shape[0] > expected:
348
- if not np.allclose(origin, origin[0], atol=0, rtol=0):
349
- logger.warning(
350
- "VisuCorePosition has %s entries but expected %s; "
351
- "using the first %s entry/entries.",
352
- origin.shape[0],
353
- expected,
354
- expected,
355
- )
356
- if rotate.ndim == 2 and rotate.shape[1] == 9 and rotate.shape[0] >= expected:
357
- rotate = rotate[:expected, :]
358
- if origin.ndim == 2 and origin.shape[1] == 3 and origin.shape[0] >= expected:
359
- origin = origin[:expected, :]
360
- rotate = rotate.reshape((num_slicepack, num_slices[spack_idx], 9))
361
- origin = origin.reshape((num_slicepack, num_slices[spack_idx], 3))
362
- _rotate = rotate[spack_idx]
363
- _origin = origin[spack_idx]
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")
364
382
  _num_slices = num_slices[spack_idx]
365
383
  _slice_thickness = slice_thickness[spack_idx]
366
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
+
367
391
  row = _rotate[0, 0:3]
368
392
  col = _rotate[0, 3:6]
369
393
  slc = _rotate[0, 6:9]
@@ -55,3 +55,8 @@ $defs:
55
55
  type: object
56
56
  description: "Conditions evaluated against mapped values."
57
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"
@@ -41,6 +41,10 @@ $defs:
41
41
  type: object
42
42
  additionalProperties: { $ref: "#/$defs/Input" }
43
43
  description: "Named inputs that are passed to a transform or returned as-is."
44
+ const: {}
45
+ ref:
46
+ type: string
47
+ description: "Dot path to a previously resolved output value."
44
48
  transform:
45
49
  anyOf:
46
50
  - type: string
@@ -52,6 +56,8 @@ $defs:
52
56
  oneOf:
53
57
  - required: ["sources"]
54
58
  - required: ["inputs"]
59
+ - required: ["const"]
60
+ - required: ["ref"]
55
61
 
56
62
  Source:
57
63
  type: object
brkraw/specs/__init__.py CHANGED
@@ -1,9 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from . import converter, pruner, remapper, rules
3
+ from . import hook, pruner, remapper, rules
4
4
 
5
5
  __all__ = [
6
- "converter",
6
+ "hook",
7
7
  "pruner",
8
8
  "remapper",
9
9
  "rules",
@@ -28,3 +28,4 @@ def resolve_hook(
28
28
 
29
29
 
30
30
  __all__ = ["DEFAULT_GROUP", "resolve_hook"]
31
+
@@ -19,3 +19,4 @@ def validate_hook(hook: Any, *, raise_on_error: bool = True) -> List[str]:
19
19
  if errors and raise_on_error:
20
20
  raise ValueError("Invalid converter hook:\n" + "\n".join(errors))
21
21
  return errors
22
+