brkraw-viewer 0.2.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.
@@ -0,0 +1,1689 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import dataclasses
5
+ import importlib
6
+ import inspect
7
+ import tkinter as tk
8
+ import logging
9
+ import json
10
+ import yaml
11
+ from pathlib import Path
12
+ from tkinter import filedialog, ttk
13
+ from typing import Any, Callable, Dict, Iterable, Mapping, Optional, cast, get_args, get_origin, get_type_hints
14
+
15
+ from brkraw.core import config as config_core
16
+ from brkraw.core import layout as layout_core
17
+ from brkraw.core.config import resolve_root
18
+ from brkraw.resolver import affine as affine_resolver
19
+ from brkraw.resolver.affine import SubjectPose, SubjectType
20
+ from brkraw.specs import hook as converter_core
21
+ from brkraw.specs import remapper as remapper_core
22
+
23
+ logger = logging.getLogger("brkraw.viewer")
24
+
25
+ class ConvertTabMixin:
26
+ # The concrete host (ViewerApp) provides these attributes/methods.
27
+ # They are declared here to keep type checkers (pyright/pylance) happy.
28
+ _loader: Any
29
+ _scan: Any
30
+ _current_reco_id: Optional[int]
31
+ _status_var: tk.StringVar
32
+
33
+ _use_layout_entries_var: tk.BooleanVar
34
+ _layout_source_var: tk.StringVar
35
+ _layout_auto_var: tk.BooleanVar
36
+ _layout_template_var: tk.StringVar
37
+ _layout_template_entry: ttk.Entry
38
+ _layout_template_combo: Optional[ttk.Combobox]
39
+ _layout_source_combo: Optional[ttk.Combobox]
40
+ _layout_auto_check: Optional[ttk.Checkbutton]
41
+ _slicepack_suffix_var: tk.StringVar
42
+ _output_dir_var: tk.StringVar
43
+ _layout_rule_display_var: tk.StringVar
44
+ _layout_info_spec_display_var: tk.StringVar
45
+ _layout_metadata_spec_display_var: tk.StringVar
46
+ _layout_context_map_display_var: tk.StringVar
47
+ _layout_template_manual: str
48
+
49
+ _layout_info_spec_name_var: tk.StringVar
50
+ _layout_info_spec_match_var: tk.StringVar
51
+ _layout_metadata_spec_name_var: tk.StringVar
52
+ _layout_metadata_spec_match_var: tk.StringVar
53
+ _layout_info_spec_file_var: tk.StringVar
54
+ _layout_metadata_spec_file_var: tk.StringVar
55
+ _layout_info_spec_combo: Optional[ttk.Combobox]
56
+ _layout_metadata_spec_combo: Optional[ttk.Combobox]
57
+ _layout_key_listbox: Optional[tk.Listbox]
58
+ _layout_key_source_signature: Optional[tuple[Any, ...]]
59
+ _layout_keys_title: tk.StringVar
60
+ _layout_key_add_button: Optional[ttk.Button]
61
+ _layout_key_remove_button: Optional[ttk.Button]
62
+ _addon_context_map_var: tk.StringVar
63
+ _addon_output_payload: Optional[Any]
64
+ _convert_sidecar_var: tk.BooleanVar
65
+ _convert_sidecar_format_var: tk.StringVar
66
+ _sidecar_format_frame: ttk.Frame
67
+ _rule_name_var: tk.StringVar
68
+
69
+ def _resolve_spec_path(self) -> Optional[str]: ...
70
+ def _spec_record_from_path(self, path: Optional[str]) -> dict[str, Any]: ...
71
+ def _auto_applied_rule(self) -> Optional[dict[str, Any]]: ...
72
+ # def _on_layout_template_change(self) -> None: ...
73
+
74
+ _convert_space_var: tk.StringVar
75
+ _convert_use_viewer_pose_var: tk.BooleanVar
76
+ _convert_flip_x_var: tk.BooleanVar
77
+ _convert_flip_y_var: tk.BooleanVar
78
+ _convert_flip_z_var: tk.BooleanVar
79
+ _convert_subject_type_var: tk.StringVar
80
+ _convert_pose_primary_var: tk.StringVar
81
+ _convert_pose_secondary_var: tk.StringVar
82
+ _convert_subject_type_combo: ttk.Combobox
83
+ _convert_pose_primary_combo: ttk.Combobox
84
+ _convert_pose_secondary_combo: ttk.Combobox
85
+ _convert_flip_x_check: ttk.Checkbutton
86
+ _convert_flip_y_check: ttk.Checkbutton
87
+ _convert_flip_z_check: ttk.Checkbutton
88
+ _convert_settings_text: Optional[tk.Text]
89
+ _convert_preview_text: Optional[tk.Text]
90
+ _convert_hook_frame: Optional[ttk.LabelFrame]
91
+ _convert_hook_name_var: tk.StringVar
92
+ _convert_hook_status_var: tk.StringVar
93
+ _convert_hook_option_vars: Dict[str, tk.StringVar]
94
+ _convert_hook_option_defaults: Dict[str, Any]
95
+ _convert_hook_option_types: Dict[str, str]
96
+ _convert_hook_option_choices: Dict[str, Dict[str, Any]]
97
+ _convert_hook_option_rows: list[tk.Widget]
98
+ _convert_hook_options_container: Optional[ttk.Frame]
99
+ _convert_hook_options_window: Optional[tk.Toplevel]
100
+ _convert_hook_edit_button: Optional[ttk.Button]
101
+ _convert_hook_current_name: str
102
+ _convert_hook_check: Optional[ttk.Checkbutton]
103
+ _viewer_hook_enabled_var: tk.BooleanVar
104
+
105
+ def _on_viewer_hook_toggle(self) -> None: ...
106
+
107
+ _subject_type_var: tk.StringVar
108
+ _pose_primary_var: tk.StringVar
109
+ _pose_secondary_var: tk.StringVar
110
+ _affine_flip_x_var: tk.BooleanVar
111
+ _affine_flip_y_var: tk.BooleanVar
112
+ _affine_flip_z_var: tk.BooleanVar
113
+
114
+ def _installed_specs(self) -> list[dict[str, Any]]: ...
115
+ def _auto_selected_spec_path(self, kind: str) -> Optional[str]: ...
116
+ def _resolve_installed_spec_path(self, *, name: str, kind: str) -> Optional[str]: ...
117
+ @staticmethod
118
+ def _cast_subject_type(value: Optional[str]) -> SubjectType: ...
119
+
120
+ @staticmethod
121
+ def _cast_subject_pose(value: Optional[str]) -> SubjectPose: ...
122
+
123
+ _PRESET_IGNORE_PARAMS = frozenset(
124
+ {
125
+ "self",
126
+ "scan",
127
+ "scan_id",
128
+ "reco_id",
129
+ "format",
130
+ "space",
131
+ "override_header",
132
+ "override_subject_type",
133
+ "override_subject_pose",
134
+ "flip_x",
135
+ "xyz_units",
136
+ "t_units",
137
+ "decimals",
138
+ "spec",
139
+ "context_map",
140
+ "return_spec",
141
+ "hook_args_by_name",
142
+ }
143
+ )
144
+
145
+ def _build_convert_tab(self, layout_tab: ttk.Frame) -> None:
146
+ layout_tab.columnconfigure(0, weight=1)
147
+ layout_tab.rowconfigure(1, weight=1)
148
+
149
+ output_layout = ttk.LabelFrame(layout_tab, text="Output Layout", padding=(8, 8))
150
+ output_layout.grid(row=0, column=0, sticky="ew", pady=(0, 10))
151
+ left_width = 350
152
+ output_layout.columnconfigure(0, minsize=left_width)
153
+ output_layout.columnconfigure(1, weight=1)
154
+
155
+ layout_left = ttk.Frame(output_layout)
156
+ layout_left.grid(row=0, column=0, sticky="nsew")
157
+ layout_left.columnconfigure(1, weight=1)
158
+ layout_left.columnconfigure(2, weight=0)
159
+
160
+ ttk.Label(layout_left, text="Layout source").grid(row=0, column=0, sticky="w")
161
+ self._layout_source_combo = ttk.Combobox(
162
+ layout_left,
163
+ textvariable=self._layout_source_var,
164
+ values=tuple(self._layout_source_choices()),
165
+ state="readonly",
166
+ )
167
+ self._layout_source_combo.grid(row=0, column=1, sticky="ew")
168
+ self._layout_source_combo.configure(width=14)
169
+ self._layout_source_combo.bind("<<ComboboxSelected>>", lambda *_: self._update_layout_controls())
170
+ self._layout_auto_check = ttk.Checkbutton(
171
+ layout_left,
172
+ text="Auto",
173
+ variable=self._layout_auto_var,
174
+ command=self._update_layout_controls,
175
+ )
176
+ self._layout_auto_check.grid(row=0, column=2, sticky="w", padx=(8, 0))
177
+
178
+ ttk.Label(layout_left, text="Rule").grid(row=1, column=0, sticky="w", pady=(8, 0))
179
+ ttk.Entry(layout_left, textvariable=self._layout_rule_display_var, state="readonly").grid(
180
+ row=1, column=1, columnspan=2, sticky="ew", pady=(8, 0)
181
+ )
182
+
183
+ ttk.Label(layout_left, text="Info spec").grid(row=2, column=0, sticky="w", pady=(6, 0))
184
+ ttk.Entry(layout_left, textvariable=self._layout_info_spec_display_var, state="readonly").grid(
185
+ row=2, column=1, columnspan=2, sticky="ew", pady=(6, 0)
186
+ )
187
+
188
+ ttk.Label(layout_left, text="Metadata spec").grid(row=3, column=0, sticky="w", pady=(6, 0))
189
+ ttk.Entry(layout_left, textvariable=self._layout_metadata_spec_display_var, state="readonly").grid(
190
+ row=3, column=1, columnspan=2, sticky="ew", pady=(6, 0)
191
+ )
192
+
193
+ ttk.Label(layout_left, text="Context map").grid(row=4, column=0, sticky="w", pady=(6, 0))
194
+ ttk.Entry(layout_left, textvariable=self._layout_context_map_display_var, state="readonly").grid(
195
+ row=4, column=1, columnspan=2, sticky="ew", pady=(6, 0)
196
+ )
197
+
198
+ ttk.Label(layout_left, text="Template").grid(row=5, column=0, sticky="w", pady=(10, 0))
199
+ self._layout_template_entry = ttk.Entry(layout_left, textvariable=self._layout_template_var)
200
+ self._layout_template_entry.grid(row=5, column=1, columnspan=2, sticky="ew", pady=(10, 0))
201
+ self._layout_template_combo = None
202
+
203
+ ttk.Label(layout_left, text="Slicepack suffix").grid(row=6, column=0, sticky="w", pady=(6, 0))
204
+ ttk.Entry(layout_left, textvariable=self._slicepack_suffix_var, state="readonly").grid(
205
+ row=6, column=1, columnspan=2, sticky="ew", pady=(6, 0)
206
+ )
207
+
208
+ keys_frame = ttk.LabelFrame(output_layout, text="Keys", padding=(6, 6))
209
+ keys_frame.grid(row=0, column=1, rowspan=7, sticky="nsew", padx=(10, 0))
210
+ keys_frame.columnconfigure(0, weight=1)
211
+ keys_frame.rowconfigure(1, weight=1)
212
+ self._layout_keys_title = tk.StringVar(value="Key (select then +)")
213
+ ttk.Label(keys_frame, textvariable=self._layout_keys_title).grid_remove()
214
+ self._layout_key_listbox = tk.Listbox(keys_frame, width=28, height=10, exportselection=False)
215
+ self._layout_key_listbox.grid(row=1, column=0, sticky="nsew")
216
+ keys_scroll = ttk.Scrollbar(keys_frame, orient="vertical", command=self._layout_key_listbox.yview)
217
+ keys_scroll.grid(row=1, column=1, sticky="ns")
218
+ self._layout_key_listbox.configure(yscrollcommand=keys_scroll.set)
219
+ self._layout_key_listbox.bind("<Button-1>", self._on_layout_key_mouse_down)
220
+ self._layout_key_listbox.bind("<ButtonRelease-1>", self._on_layout_key_click)
221
+ self._layout_key_listbox.bind("<Double-Button-1>", self._on_layout_key_double_click)
222
+
223
+ self._layout_key_add_button = None
224
+ self._layout_key_remove_button = None
225
+ key_buttons = ttk.Frame(keys_frame)
226
+ key_buttons.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(6, 0))
227
+ key_buttons.columnconfigure(0, weight=1)
228
+ key_buttons.columnconfigure(1, weight=1)
229
+ self._layout_key_add_button = ttk.Button(key_buttons, text="+", command=self._add_selected_layout_key)
230
+ self._layout_key_add_button.grid(row=0, column=0, sticky="ew")
231
+ self._layout_key_remove_button = ttk.Button(key_buttons, text="-", command=self._remove_selected_layout_key)
232
+ self._layout_key_remove_button.grid(row=0, column=1, sticky="ew", padx=(6, 0))
233
+
234
+ convert_frame = ttk.Frame(layout_tab, padding=(8, 8))
235
+ convert_frame.grid(row=1, column=0, sticky="nsew")
236
+ convert_frame.columnconfigure(0, minsize=left_width)
237
+ convert_frame.columnconfigure(1, weight=1)
238
+ convert_frame.rowconfigure(0, weight=1)
239
+
240
+ convert_left = ttk.Frame(convert_frame)
241
+ convert_left.grid(row=0, column=0, sticky="nsew")
242
+ convert_left.grid_propagate(False)
243
+ convert_left.configure(width=left_width)
244
+ convert_left.columnconfigure(0, weight=1)
245
+
246
+ output_row = ttk.Frame(convert_left)
247
+ output_row.grid(row=0, column=0, sticky="ew", pady=(0, 6))
248
+ output_row.columnconfigure(1, weight=1)
249
+ ttk.Label(output_row, text="Output folder").grid(row=0, column=0, sticky="w")
250
+ ttk.Entry(output_row, textvariable=self._output_dir_var).grid(row=0, column=1, sticky="ew", padx=(8, 6))
251
+ ttk.Button(output_row, text="Browse", command=self._browse_output_dir).grid(row=0, column=2, sticky="e")
252
+
253
+ sidecar_row = ttk.Frame(convert_left)
254
+ sidecar_row.grid(row=1, column=0, sticky="w", pady=(0, 10))
255
+ ttk.Checkbutton(
256
+ sidecar_row,
257
+ text="Metadata Sidecar",
258
+ variable=self._convert_sidecar_var,
259
+ command=self._update_sidecar_controls,
260
+ ).pack(side=tk.LEFT)
261
+ self._sidecar_format_frame = ttk.Frame(sidecar_row)
262
+ self._sidecar_format_frame.pack(side=tk.LEFT, padx=(12, 0))
263
+ ttk.Label(self._sidecar_format_frame, text="Format").pack(side=tk.LEFT)
264
+ ttk.Radiobutton(
265
+ self._sidecar_format_frame,
266
+ text="JSON",
267
+ value="json",
268
+ variable=self._convert_sidecar_format_var,
269
+ ).pack(side=tk.LEFT, padx=(6, 0))
270
+ ttk.Radiobutton(
271
+ self._sidecar_format_frame,
272
+ text="YAML",
273
+ value="yaml",
274
+ variable=self._convert_sidecar_format_var,
275
+ ).pack(side=tk.LEFT, padx=(6, 0))
276
+
277
+ use_viewer_row = ttk.Frame(convert_left)
278
+ use_viewer_row.grid(row=2, column=0, sticky="w")
279
+ ttk.Checkbutton(
280
+ use_viewer_row,
281
+ text="Use Viewer orientation",
282
+ variable=self._convert_use_viewer_pose_var,
283
+ command=self._update_convert_space_controls,
284
+ ).pack(side=tk.LEFT)
285
+
286
+ space_row = ttk.Frame(convert_left)
287
+ space_row.grid(row=3, column=0, sticky="ew", pady=(6, 0))
288
+ space_row.columnconfigure(1, weight=1, uniform="orient")
289
+ ttk.Label(space_row, text="Space").grid(row=0, column=0, sticky="w")
290
+ self._convert_space_combo = ttk.Combobox(
291
+ space_row,
292
+ textvariable=self._convert_space_var,
293
+ values=("raw", "scanner", "subject_ras"),
294
+ state="readonly",
295
+ width=14,
296
+ )
297
+ self._convert_space_combo.grid(row=0, column=1, sticky="ew", padx=(8, 0))
298
+ self._convert_space_combo.bind("<<ComboboxSelected>>", lambda *_: self._update_convert_space_controls())
299
+
300
+ subject_row = ttk.Frame(convert_left)
301
+ subject_row.columnconfigure(1, weight=1)
302
+ self._convert_subject_type_combo = ttk.Combobox(
303
+ subject_row,
304
+ textvariable=self._convert_subject_type_var,
305
+ values=("Biped", "Quadruped", "Phantom", "Other", "OtherAnimal"),
306
+ state="disabled",
307
+ )
308
+ self._convert_subject_type_combo.grid(row=0, column=1, sticky="ew")
309
+
310
+ pose_row = ttk.Frame(convert_left)
311
+ pose_row.grid(row=5, column=0, sticky="ew", pady=(6, 0))
312
+ pose_row.columnconfigure(1, weight=1, uniform="orient")
313
+ pose_row.columnconfigure(2, weight=1, uniform="orient")
314
+ ttk.Label(pose_row, text="Pose").grid(row=0, column=0, sticky="w")
315
+ self._convert_pose_primary_combo = ttk.Combobox(
316
+ pose_row,
317
+ textvariable=self._convert_pose_primary_var,
318
+ values=("Head", "Foot"),
319
+ state="disabled",
320
+ )
321
+ self._convert_pose_primary_combo.grid(row=0, column=1, sticky="ew", padx=(8, 4))
322
+ self._convert_pose_secondary_combo = ttk.Combobox(
323
+ pose_row,
324
+ textvariable=self._convert_pose_secondary_var,
325
+ values=("Supine", "Prone", "Left", "Right"),
326
+ state="disabled",
327
+ )
328
+ self._convert_pose_secondary_combo.grid(row=0, column=2, sticky="ew")
329
+
330
+ flip_row = ttk.Frame(convert_left)
331
+ flip_row.grid(row=6, column=0, sticky="w", pady=(0, 0))
332
+ ttk.Label(flip_row, text="Flip").pack(side=tk.LEFT, padx=(0, 6))
333
+ self._convert_flip_x_check = ttk.Checkbutton(
334
+ flip_row,
335
+ text="X",
336
+ variable=self._convert_flip_x_var,
337
+ )
338
+ self._convert_flip_x_check.pack(side=tk.LEFT)
339
+ self._convert_flip_y_check = ttk.Checkbutton(
340
+ flip_row,
341
+ text="Y",
342
+ variable=self._convert_flip_y_var,
343
+ )
344
+ self._convert_flip_y_check.pack(side=tk.LEFT, padx=(6, 0))
345
+ self._convert_flip_z_check = ttk.Checkbutton(
346
+ flip_row,
347
+ text="Z",
348
+ variable=self._convert_flip_z_var,
349
+ )
350
+ self._convert_flip_z_check.pack(side=tk.LEFT, padx=(6, 0))
351
+
352
+ self._convert_hook_frame = ttk.LabelFrame(convert_left, text="", padding=(6, 6))
353
+ self._convert_hook_frame.grid(row=7, column=0, sticky="ew", pady=(0, 0))
354
+ self._convert_hook_frame.columnconfigure(2, weight=1)
355
+ self._convert_hook_frame.columnconfigure(3, weight=0)
356
+
357
+ self._convert_hook_check = ttk.Checkbutton(
358
+ self._convert_hook_frame,
359
+ text="",
360
+ variable=self._viewer_hook_enabled_var,
361
+ command=self._on_viewer_hook_toggle,
362
+ )
363
+ self._convert_hook_check.grid(row=0, column=0, sticky="w", padx=(0, 6))
364
+ ttk.Label(self._convert_hook_frame, text="Available Hook:").grid(row=0, column=1, sticky="w")
365
+ ttk.Label(self._convert_hook_frame, textvariable=self._convert_hook_name_var).grid(
366
+ row=0, column=2, sticky="w", padx=(6, 0)
367
+ )
368
+ self._convert_hook_edit_button = ttk.Button(
369
+ self._convert_hook_frame,
370
+ text="Edit Options",
371
+ command=self._open_convert_hook_options,
372
+ )
373
+ self._convert_hook_edit_button.grid(row=0, column=3, sticky="e", padx=(8, 0))
374
+
375
+ actions = ttk.Frame(convert_left)
376
+ actions.grid(row=8, column=0, sticky="ew", pady=(10, 0))
377
+ actions.columnconfigure(0, weight=1, uniform="convert_actions")
378
+ actions.columnconfigure(1, weight=1, uniform="convert_actions")
379
+ ttk.Button(actions, text="Preview Outputs", command=self._preview_convert_outputs).grid(row=0, column=0, sticky="ew")
380
+ ttk.Button(actions, text="Convert", command=self._convert_current_scan).grid(row=0, column=1, sticky="ew", padx=(8, 0))
381
+
382
+ self._update_sidecar_controls()
383
+
384
+ preview_box = ttk.LabelFrame(convert_frame, text="Output Preview", padding=(6, 6))
385
+ preview_box.grid(row=0, column=1, sticky="nsew")
386
+ preview_box.columnconfigure(0, weight=1)
387
+ preview_box.columnconfigure(1, weight=0)
388
+ preview_box.rowconfigure(0, weight=1)
389
+ preview_box.rowconfigure(1, weight=0)
390
+
391
+ self._convert_settings_text = tk.Text(preview_box, wrap="word", height=10)
392
+ self._convert_settings_text.grid(row=0, column=0, sticky="nsew", pady=(0, 6))
393
+ settings_scroll = ttk.Scrollbar(preview_box, orient="vertical", command=self._convert_settings_text.yview)
394
+ settings_scroll.grid(row=0, column=1, sticky="ns", pady=(0, 6))
395
+ self._convert_settings_text.configure(yscrollcommand=settings_scroll.set)
396
+ self._convert_settings_text.configure(state=tk.DISABLED)
397
+
398
+ self._convert_preview_text = tk.Text(preview_box, wrap="none", height=3)
399
+ self._convert_preview_text.grid(row=1, column=0, sticky="ew")
400
+ preview_scroll_y = ttk.Scrollbar(preview_box, orient="vertical", command=self._convert_preview_text.yview)
401
+ preview_scroll_y.grid(row=1, column=1, sticky="ns")
402
+ preview_scroll_x = ttk.Scrollbar(preview_box, orient="horizontal", command=self._convert_preview_text.xview)
403
+ preview_scroll_x.grid(row=2, column=0, columnspan=2, sticky="ew")
404
+ self._convert_preview_text.configure(yscrollcommand=preview_scroll_y.set, xscrollcommand=preview_scroll_x.set)
405
+ self._convert_preview_text.configure(state=tk.DISABLED)
406
+
407
+ self._refresh_convert_hook_options()
408
+
409
+ def _browse_output_dir(self) -> None:
410
+ path = filedialog.askdirectory(title="Select output directory")
411
+ if not path:
412
+ return
413
+ self._output_dir_var.set(path)
414
+
415
+ def _set_convert_preview(self, text: str) -> None:
416
+ if self._convert_preview_text is None:
417
+ return
418
+ self._convert_preview_text.configure(state=tk.NORMAL)
419
+ self._convert_preview_text.delete("1.0", tk.END)
420
+ self._convert_preview_text.insert(tk.END, text)
421
+ self._convert_preview_text.configure(state=tk.DISABLED)
422
+
423
+ def _set_convert_settings(self, text: str) -> None:
424
+ if self._convert_settings_text is None:
425
+ return
426
+ self._convert_settings_text.configure(state=tk.NORMAL)
427
+ self._convert_settings_text.delete("1.0", tk.END)
428
+ self._convert_settings_text.insert(tk.END, text)
429
+ self._convert_settings_text.configure(state=tk.DISABLED)
430
+
431
+ def _clear_convert_hook_option_rows(self, *, clear_values: bool = False) -> None:
432
+ for widget in self._convert_hook_option_rows:
433
+ try:
434
+ widget.destroy()
435
+ except Exception:
436
+ pass
437
+ self._convert_hook_option_rows = []
438
+ if clear_values:
439
+ self._convert_hook_option_vars = {}
440
+ self._convert_hook_option_defaults = {}
441
+ self._convert_hook_option_types = {}
442
+ self._convert_hook_option_choices = {}
443
+
444
+ def _infer_hook_preset_from_module(self, module: object) -> Dict[str, Any]:
445
+ for attr in ("HOOK_PRESET", "HOOK_ARGS", "HOOK_DEFAULTS"):
446
+ value = getattr(module, attr, None)
447
+ if isinstance(value, Mapping):
448
+ return dict(value)
449
+ build_options = getattr(module, "_build_options", None)
450
+ if callable(build_options):
451
+ try:
452
+ options = build_options({})
453
+ except Exception:
454
+ return {}
455
+ if dataclasses.is_dataclass(options):
456
+ if not isinstance(options, type):
457
+ return dict(dataclasses.asdict(options))
458
+ defaults: Dict[str, Any] = {}
459
+ for field in dataclasses.fields(options):
460
+ if field.default is not dataclasses.MISSING:
461
+ defaults[field.name] = field.default
462
+ continue
463
+ if field.default_factory is not dataclasses.MISSING: # type: ignore[comparison-overlap]
464
+ try:
465
+ defaults[field.name] = field.default_factory() # type: ignore[misc]
466
+ except Exception:
467
+ defaults[field.name] = None
468
+ continue
469
+ defaults[field.name] = None
470
+ return defaults
471
+ if hasattr(options, "__dict__"):
472
+ return dict(vars(options))
473
+ return {}
474
+
475
+ def _infer_hook_preset(self, entry: Mapping[str, Any]) -> Dict[str, Any]:
476
+ preset: Dict[str, Any] = {}
477
+ modules: list[object] = []
478
+
479
+ for func in entry.values():
480
+ if callable(func):
481
+ mod_name = getattr(func, "__module__", None)
482
+ if isinstance(mod_name, str) and mod_name:
483
+ try:
484
+ modules.append(importlib.import_module(mod_name))
485
+ except Exception:
486
+ pass
487
+
488
+ for module in modules:
489
+ module_preset = self._infer_hook_preset_from_module(module)
490
+ if module_preset:
491
+ return dict(sorted(module_preset.items(), key=lambda item: item[0]))
492
+
493
+ for func in entry.values():
494
+ if not callable(func):
495
+ continue
496
+ try:
497
+ sig = inspect.signature(func)
498
+ except (TypeError, ValueError):
499
+ continue
500
+ for param in sig.parameters.values():
501
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
502
+ continue
503
+ name = param.name
504
+ if name in self._PRESET_IGNORE_PARAMS:
505
+ continue
506
+ if name in preset:
507
+ continue
508
+ if param.default is inspect.Parameter.empty:
509
+ preset[name] = None
510
+ else:
511
+ preset[name] = param.default
512
+ return dict(sorted(preset.items(), key=lambda item: item[0]))
513
+
514
+ def _infer_hook_option_hints(self, entry: Mapping[str, Any]) -> Dict[str, Any]:
515
+ hints: Dict[str, Any] = {}
516
+ modules: list[object] = []
517
+
518
+ for func in entry.values():
519
+ if callable(func):
520
+ mod_name = getattr(func, "__module__", None)
521
+ if isinstance(mod_name, str) and mod_name:
522
+ try:
523
+ modules.append(importlib.import_module(mod_name))
524
+ except Exception:
525
+ pass
526
+
527
+ for module in modules:
528
+ build_options = getattr(module, "_build_options", None)
529
+ if callable(build_options):
530
+ try:
531
+ options = build_options({})
532
+ except Exception:
533
+ options = None
534
+ if dataclasses.is_dataclass(options):
535
+ for field in dataclasses.fields(options):
536
+ if field.name not in hints:
537
+ hints[field.name] = field.type
538
+
539
+ for func in entry.values():
540
+ if not callable(func):
541
+ continue
542
+ try:
543
+ sig = inspect.signature(func)
544
+ type_hints = get_type_hints(func)
545
+ except (TypeError, ValueError):
546
+ continue
547
+ for param in sig.parameters.values():
548
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
549
+ continue
550
+ name = param.name
551
+ if name in self._PRESET_IGNORE_PARAMS or name in hints:
552
+ continue
553
+ annotation = type_hints.get(name, param.annotation)
554
+ if annotation is inspect.Parameter.empty:
555
+ continue
556
+ hints[name] = annotation
557
+ return hints
558
+
559
+ def _format_hook_type(self, value: Any, hint: Any = None) -> str:
560
+ if hint is not None:
561
+ origin = get_origin(hint)
562
+ if origin is not None and origin.__name__ == "Literal":
563
+ return "Literal"
564
+ if hint is bool:
565
+ return "bool"
566
+ if hint is int:
567
+ return "int"
568
+ if hint is float:
569
+ return "float"
570
+ if hint is str:
571
+ return "str"
572
+ if value is None:
573
+ return "Any"
574
+ if isinstance(value, bool):
575
+ return "bool"
576
+ if isinstance(value, int) and not isinstance(value, bool):
577
+ return "int"
578
+ if isinstance(value, float):
579
+ return "float"
580
+ if isinstance(value, str):
581
+ return "str"
582
+ if isinstance(value, list):
583
+ return "list"
584
+ if isinstance(value, dict):
585
+ return "dict"
586
+ return type(value).__name__
587
+
588
+ def _coerce_hook_value(self, raw: str, default: Any) -> Any:
589
+ text = raw.strip()
590
+ if text == "":
591
+ return default
592
+ if isinstance(default, bool):
593
+ return text.lower() in {"1", "true", "yes", "y", "on"}
594
+ if isinstance(default, int) and not isinstance(default, bool):
595
+ try:
596
+ return int(text)
597
+ except ValueError:
598
+ return default
599
+ if isinstance(default, float):
600
+ try:
601
+ return float(text)
602
+ except ValueError:
603
+ return default
604
+ if isinstance(default, (list, tuple, dict)):
605
+ try:
606
+ return ast.literal_eval(text)
607
+ except Exception:
608
+ try:
609
+ return json.loads(text)
610
+ except Exception:
611
+ return default
612
+ if default is None:
613
+ try:
614
+ return ast.literal_eval(text)
615
+ except Exception:
616
+ try:
617
+ return json.loads(text)
618
+ except Exception:
619
+ return text
620
+ return text
621
+
622
+ def _refresh_convert_hook_options(self, *, render_form: bool = False) -> None:
623
+ if self._convert_hook_frame is None:
624
+ return
625
+ hook_name = ""
626
+ if self._scan is not None:
627
+ hook_name = (getattr(self._scan, "_converter_hook_name", None) or "").strip()
628
+ if hook_name != self._convert_hook_current_name:
629
+ self._convert_hook_current_name = hook_name
630
+ self._clear_convert_hook_option_rows(clear_values=True)
631
+ self._convert_hook_name_var.set(hook_name or "None")
632
+ if not hook_name:
633
+ self._convert_hook_status_var.set("No converter hook detected for this scan.")
634
+ if self._convert_hook_check is not None:
635
+ self._convert_hook_check.configure(state="disabled")
636
+ if self._convert_hook_edit_button is not None:
637
+ self._convert_hook_edit_button.configure(state="disabled")
638
+ return
639
+ try:
640
+ entry = converter_core.resolve_hook(hook_name)
641
+ except Exception:
642
+ self._convert_hook_status_var.set("Converter hook not available.")
643
+ if self._convert_hook_check is not None:
644
+ self._convert_hook_check.configure(state="disabled")
645
+ if self._convert_hook_edit_button is not None:
646
+ self._convert_hook_edit_button.configure(state="disabled")
647
+ return
648
+ preset = self._infer_hook_preset(entry)
649
+ hints = self._infer_hook_option_hints(entry)
650
+ if not preset:
651
+ self._convert_hook_status_var.set("Hook has no exposed options.")
652
+ if self._convert_hook_check is not None:
653
+ self._convert_hook_check.configure(state="normal")
654
+ if self._convert_hook_edit_button is not None:
655
+ self._convert_hook_edit_button.configure(state="disabled")
656
+ return
657
+ self._convert_hook_status_var.set("")
658
+ if self._convert_hook_check is not None:
659
+ self._convert_hook_check.configure(state="normal")
660
+ if self._convert_hook_edit_button is not None:
661
+ self._convert_hook_edit_button.configure(state="normal")
662
+ if render_form:
663
+ self._render_convert_hook_form(preset, hints)
664
+
665
+ def _render_convert_hook_form(self, preset: Dict[str, Any], hints: Dict[str, Any]) -> None:
666
+ if self._convert_hook_options_container is None:
667
+ return
668
+ self._clear_convert_hook_option_rows()
669
+ ttk.Label(self._convert_hook_options_container, text="Key").grid(row=0, column=0, sticky="w")
670
+ ttk.Label(self._convert_hook_options_container, text="Type").grid(row=0, column=1, sticky="w")
671
+ ttk.Label(self._convert_hook_options_container, text="Value").grid(row=0, column=2, sticky="w")
672
+ self._convert_hook_option_rows.extend(
673
+ list(self._convert_hook_options_container.grid_slaves(row=0))
674
+ )
675
+
676
+ row = 1
677
+ self._convert_hook_option_choices = {}
678
+ for key, default in preset.items():
679
+ existing = self._convert_hook_option_vars.get(key)
680
+ var = existing or tk.StringVar(value="" if default is None else str(default))
681
+ hint = hints.get(key)
682
+ type_label = self._format_hook_type(default, hint)
683
+ self._convert_hook_option_vars[key] = var
684
+ self._convert_hook_option_defaults[key] = default
685
+ self._convert_hook_option_types[key] = type_label
686
+
687
+ key_label = ttk.Label(self._convert_hook_options_container, text=key)
688
+ key_label.grid(row=row, column=0, sticky="w", padx=(0, 6), pady=2)
689
+ type_label_widget = ttk.Label(self._convert_hook_options_container, text=type_label)
690
+ type_label_widget.grid(row=row, column=1, sticky="w", padx=(0, 6), pady=2)
691
+ widget: tk.Widget
692
+ origin = get_origin(hint) if hint is not None else None
693
+ if origin is not None and origin.__name__ == "Literal":
694
+ choices = list(get_args(hint))
695
+ if choices:
696
+ values = [str(choice) for choice in choices]
697
+ self._convert_hook_option_choices[key] = {str(choice): choice for choice in choices}
698
+ if var.get() not in values:
699
+ lower_map = {value.lower(): value for value in values}
700
+ matched = lower_map.get(var.get().lower())
701
+ var.set(matched if matched is not None else values[0])
702
+ widget = ttk.Combobox(
703
+ self._convert_hook_options_container,
704
+ textvariable=var,
705
+ values=values,
706
+ state="readonly",
707
+ )
708
+ else:
709
+ widget = ttk.Entry(self._convert_hook_options_container, textvariable=var)
710
+ elif hint is bool or isinstance(default, bool):
711
+ values = ["True", "False"]
712
+ if var.get().lower() in {"true", "false"}:
713
+ var.set("True" if var.get().lower() == "true" else "False")
714
+ if var.get() not in values:
715
+ var.set("True" if default is True else "False")
716
+ widget = ttk.Combobox(
717
+ self._convert_hook_options_container,
718
+ textvariable=var,
719
+ values=values,
720
+ state="readonly",
721
+ )
722
+ else:
723
+ widget = ttk.Entry(self._convert_hook_options_container, textvariable=var)
724
+ widget.grid(row=row, column=2, sticky="ew", pady=2)
725
+
726
+ self._convert_hook_option_rows.extend([key_label, type_label_widget, widget])
727
+ row += 1
728
+
729
+ self._convert_hook_options_container.columnconfigure(2, weight=1)
730
+
731
+ def _reset_convert_hook_options(self) -> None:
732
+ for key, var in self._convert_hook_option_vars.items():
733
+ default = self._convert_hook_option_defaults.get(key)
734
+ choices = self._convert_hook_option_choices.get(key)
735
+ if choices:
736
+ target = str(default) if default is not None else None
737
+ if target is None or target not in choices:
738
+ target = next(iter(choices.keys()), "")
739
+ var.set(target)
740
+ else:
741
+ var.set("" if default is None else str(default))
742
+
743
+ def _open_convert_hook_options(self) -> None:
744
+ self._refresh_convert_hook_options(render_form=False)
745
+ hook_name = (self._convert_hook_name_var.get() or "").strip()
746
+ if not hook_name or hook_name == "None":
747
+ return
748
+ if self._convert_hook_options_window is None or not self._convert_hook_options_window.winfo_exists():
749
+ master = cast(tk.Misc, self)
750
+ self._convert_hook_options_window = tk.Toplevel(master)
751
+ self._convert_hook_options_window.title("Converter Hook Options")
752
+ cast(Any, self._convert_hook_options_window).transient(master)
753
+ self._convert_hook_options_window.resizable(True, True)
754
+ self._convert_hook_options_window.columnconfigure(0, weight=1)
755
+ self._convert_hook_options_window.rowconfigure(0, weight=1)
756
+
757
+ container = ttk.Frame(self._convert_hook_options_window, padding=(10, 10))
758
+ container.grid(row=0, column=0, sticky="nsew")
759
+ container.columnconfigure(0, weight=1)
760
+ container.rowconfigure(1, weight=1)
761
+
762
+ header = ttk.Frame(container)
763
+ header.grid(row=0, column=0, sticky="ew", pady=(0, 6))
764
+ header.columnconfigure(1, weight=1)
765
+ ttk.Label(header, text="Hook").grid(row=0, column=0, sticky="w")
766
+ ttk.Label(header, textvariable=self._convert_hook_name_var).grid(row=0, column=1, sticky="w")
767
+
768
+ self._convert_hook_options_container = ttk.Frame(container)
769
+ self._convert_hook_options_container.grid(row=1, column=0, sticky="nsew")
770
+ self._convert_hook_options_container.columnconfigure(2, weight=1)
771
+
772
+ actions = ttk.Frame(container)
773
+ actions.grid(row=2, column=0, sticky="ew", pady=(8, 0))
774
+ actions.columnconfigure(0, weight=1)
775
+
776
+ def _save_options() -> None:
777
+ window = self._convert_hook_options_window
778
+ if window is not None:
779
+ window.withdraw()
780
+
781
+ ttk.Button(actions, text="Reset", command=self._reset_convert_hook_options).grid(
782
+ row=0, column=0, sticky="w"
783
+ )
784
+ ttk.Button(actions, text="Save", command=_save_options).grid(row=0, column=1, sticky="e")
785
+ def _close_options() -> None:
786
+ window = self._convert_hook_options_window
787
+ if window is not None:
788
+ window.withdraw()
789
+
790
+ ttk.Button(actions, text="Close", command=_close_options).grid(
791
+ row=0, column=2, sticky="e", padx=(8, 0)
792
+ )
793
+
794
+ try:
795
+ entry = converter_core.resolve_hook(hook_name)
796
+ except Exception:
797
+ return
798
+ preset = self._infer_hook_preset(entry)
799
+ hints = self._infer_hook_option_hints(entry)
800
+ if not preset:
801
+ return
802
+ self._render_convert_hook_form(preset, hints)
803
+ window = self._convert_hook_options_window
804
+ if window is not None:
805
+ window.deiconify()
806
+ window.lift()
807
+
808
+ def _collect_convert_hook_args(self) -> Optional[Dict[str, Dict[str, Any]]]:
809
+ hook_name = ""
810
+ if self._scan is not None:
811
+ hook_name = (getattr(self._scan, "_converter_hook_name", None) or "").strip()
812
+ if not hook_name:
813
+ logger.debug("No converter hook name found for hook args.")
814
+ return None
815
+ if not self._convert_hook_option_vars:
816
+ logger.debug("No converter hook option vars set for %s.", hook_name)
817
+ return None
818
+ values: Dict[str, Any] = {}
819
+ for key, var in self._convert_hook_option_vars.items():
820
+ choices = self._convert_hook_option_choices.get(key)
821
+ if choices is not None:
822
+ raw = var.get()
823
+ values[key] = choices.get(raw, raw)
824
+ continue
825
+ default = self._convert_hook_option_defaults.get(key)
826
+ values[key] = self._coerce_hook_value(var.get(), default)
827
+ hook_args = {hook_name: values}
828
+ logger.debug("Convert hook args resolved: %s", hook_args)
829
+ return hook_args
830
+
831
+ def _update_convert_space_controls(self) -> None:
832
+ if self._convert_use_viewer_pose_var.get():
833
+ viewer_space = (getattr(self, "_space_var", tk.StringVar(value="scanner")).get() or "").strip()
834
+ if viewer_space:
835
+ self._convert_space_var.set(viewer_space)
836
+ if getattr(self, "_convert_space_combo", None) is not None:
837
+ self._convert_space_combo.configure(state="disabled")
838
+ else:
839
+ if getattr(self, "_convert_space_combo", None) is not None:
840
+ self._convert_space_combo.configure(state="readonly")
841
+ enabled = self._convert_space_var.get() == "subject_ras"
842
+ logger.debug(
843
+ "Update convert controls: use_viewer=%s space=%s",
844
+ bool(self._convert_use_viewer_pose_var.get()),
845
+ (self._convert_space_var.get() or "").strip(),
846
+ )
847
+ if self._convert_use_viewer_pose_var.get():
848
+ self._convert_subject_type_combo.configure(state="disabled")
849
+ self._convert_pose_primary_combo.configure(state="disabled")
850
+ self._convert_pose_secondary_combo.configure(state="disabled")
851
+ self._sync_convert_with_viewer_orientation()
852
+ self._convert_flip_x_check.configure(state="disabled")
853
+ self._convert_flip_y_check.configure(state="disabled")
854
+ self._convert_flip_z_check.configure(state="disabled")
855
+ return
856
+ state = "readonly" if enabled else "disabled"
857
+ self._convert_subject_type_combo.configure(state=state)
858
+ self._convert_pose_primary_combo.configure(state=state)
859
+ self._convert_pose_secondary_combo.configure(state=state)
860
+ flip_state = "normal" if enabled else "disabled"
861
+ self._convert_flip_x_check.configure(state=flip_state)
862
+ self._convert_flip_y_check.configure(state=flip_state)
863
+ self._convert_flip_z_check.configure(state=flip_state)
864
+
865
+ def _sync_convert_with_viewer_orientation(self) -> None:
866
+ if not self._convert_use_viewer_pose_var.get():
867
+ return
868
+ subject_type = (self._subject_type_var.get() or "Biped").strip()
869
+ pose_primary = (self._pose_primary_var.get() or "Head").strip()
870
+ pose_secondary = (self._pose_secondary_var.get() or "Supine").strip()
871
+ flip_x = bool(self._affine_flip_x_var.get())
872
+ flip_y = bool(self._affine_flip_y_var.get())
873
+ flip_z = bool(self._affine_flip_z_var.get())
874
+ logger.debug(
875
+ "Sync convert with viewer orientation: type=%s pose=%s_%s flip=(%s,%s,%s)",
876
+ subject_type,
877
+ pose_primary,
878
+ pose_secondary,
879
+ flip_x,
880
+ flip_y,
881
+ flip_z,
882
+ )
883
+ self._convert_subject_type_var.set(subject_type)
884
+ self._convert_pose_primary_var.set(pose_primary)
885
+ self._convert_pose_secondary_var.set(pose_secondary)
886
+ self._convert_flip_x_var.set(flip_x)
887
+ self._convert_flip_y_var.set(flip_y)
888
+ self._convert_flip_z_var.set(flip_z)
889
+
890
+ def _update_layout_controls(self) -> None:
891
+ self._sync_layout_source_state()
892
+ self._refresh_layout_display()
893
+ template_enabled = self._layout_template_enabled()
894
+ button_state = "normal" if template_enabled else "disabled"
895
+ for btn in (getattr(self, "_layout_key_add_button", None), getattr(self, "_layout_key_remove_button", None)):
896
+ if btn is None:
897
+ continue
898
+ try:
899
+ btn.configure(state=button_state)
900
+ except Exception:
901
+ pass
902
+ try:
903
+ if template_enabled:
904
+ self._layout_template_entry.state(["!disabled"])
905
+ else:
906
+ self._layout_template_entry.state(["disabled"])
907
+ except Exception:
908
+ pass
909
+ if not template_enabled and self._layout_key_listbox is not None:
910
+ self._layout_key_listbox.selection_clear(0, tk.END)
911
+ if self._layout_key_listbox is not None:
912
+ try:
913
+ self._layout_key_listbox.configure(state=tk.NORMAL if template_enabled else tk.DISABLED)
914
+ except Exception:
915
+ pass
916
+ self._refresh_layout_keys()
917
+
918
+ def _update_sidecar_controls(self) -> None:
919
+ enable = bool(self._convert_sidecar_var.get())
920
+ for child in getattr(self, "_sidecar_format_frame", ttk.Frame()).winfo_children():
921
+ try:
922
+ state_fn = getattr(child, "state", None)
923
+ if callable(state_fn):
924
+ state_fn(["!disabled"] if enable else ["disabled"])
925
+ except Exception:
926
+ pass
927
+
928
+ def _layout_source_choices(self) -> list[str]:
929
+ return ["GUI template", "Context map", "Config"]
930
+
931
+ def _layout_source_mode(self) -> str:
932
+ if bool(self._layout_auto_var.get()):
933
+ return "auto"
934
+ value = (self._layout_source_var.get() or "").strip()
935
+ if value not in self._layout_source_choices():
936
+ return "Config"
937
+ return value
938
+
939
+ def _has_context_map(self) -> bool:
940
+ path = (self._addon_context_map_var.get() or "").strip()
941
+ return bool(path) and Path(path).exists()
942
+
943
+ def _sync_layout_source_state(self) -> None:
944
+ if self._layout_source_combo is None:
945
+ return
946
+ if bool(self._layout_auto_var.get()):
947
+ self._layout_source_combo.configure(state="disabled")
948
+ return
949
+ self._layout_source_combo.configure(state="readonly")
950
+ if self._layout_source_var.get() not in self._layout_source_choices():
951
+ self._layout_source_var.set("Config")
952
+ if not self._has_context_map() and self._layout_source_var.get() == "Context map":
953
+ self._layout_source_var.set("Config")
954
+
955
+ def _layout_template_enabled(self) -> bool:
956
+ if bool(self._layout_auto_var.get()):
957
+ return False
958
+ return self._layout_source_var.get() == "GUI template"
959
+
960
+ def _on_layout_template_change(self) -> None:
961
+ if not bool(self._layout_auto_var.get()):
962
+ self._layout_template_manual = (self._layout_template_var.get() or "")
963
+
964
+ def _refresh_layout_display(self) -> None:
965
+ rule_display = ""
966
+ try:
967
+ rule_name = (self._rule_name_var.get() or "").strip()
968
+ if rule_name and rule_name != "None":
969
+ rule_display = rule_name
970
+ except Exception:
971
+ rule_display = ""
972
+ if not rule_display:
973
+ auto_rule = self._auto_applied_rule()
974
+ if auto_rule is not None:
975
+ rule_display = str(auto_rule.get("name") or "")
976
+ self._layout_rule_display_var.set(rule_display)
977
+
978
+ info_spec = ""
979
+ meta_spec = ""
980
+ spec_path = None
981
+ try:
982
+ spec_path = self._resolve_spec_path()
983
+ except Exception:
984
+ spec_path = None
985
+ if spec_path:
986
+ record = self._spec_record_from_path(spec_path)
987
+ category = record.get("category")
988
+ if category == "metadata_spec":
989
+ meta_spec = spec_path
990
+ else:
991
+ info_spec = spec_path
992
+ if not info_spec:
993
+ info_spec = self._auto_selected_spec_path("info_spec") or ""
994
+ if not meta_spec:
995
+ meta_spec = self._auto_selected_spec_path("metadata_spec") or ""
996
+ if not info_spec:
997
+ info_spec = "Default"
998
+ if not meta_spec:
999
+ meta_spec = "None"
1000
+ self._layout_info_spec_display_var.set(info_spec)
1001
+ self._layout_metadata_spec_display_var.set(meta_spec)
1002
+
1003
+ context_map = (self._addon_context_map_var.get() or "").strip()
1004
+ self._layout_context_map_display_var.set(context_map)
1005
+
1006
+ if info_spec and info_spec != "Default":
1007
+ self._layout_info_spec_file_var.set(info_spec)
1008
+ else:
1009
+ self._layout_info_spec_file_var.set("")
1010
+ if meta_spec and meta_spec != "None":
1011
+ self._layout_metadata_spec_file_var.set(meta_spec)
1012
+ else:
1013
+ self._layout_metadata_spec_file_var.set("")
1014
+
1015
+ if bool(self._layout_auto_var.get()):
1016
+ active_template = self._current_layout_template_from_sources()
1017
+ self._layout_template_var.set(active_template)
1018
+ self._slicepack_suffix_var.set(self._current_slicepack_suffix())
1019
+ self._layout_source_var.set(self._auto_layout_source())
1020
+ else:
1021
+ if self._layout_template_manual:
1022
+ if (self._layout_template_var.get() or "") != self._layout_template_manual:
1023
+ self._layout_template_var.set(self._layout_template_manual)
1024
+ self._slicepack_suffix_var.set(self._current_slicepack_suffix())
1025
+
1026
+ def _current_layout_template_from_sources(self) -> str:
1027
+ layout_template, _, _, _ = self._resolve_layout_sources(reco_id=self._current_reco_id)
1028
+ return layout_template or ""
1029
+
1030
+ def _auto_layout_source(self) -> str:
1031
+ if self._layout_template_manual:
1032
+ return "GUI template"
1033
+ if self._context_map_has_layout():
1034
+ return "Context map"
1035
+ return "Config"
1036
+
1037
+ def _context_map_has_layout(self) -> bool:
1038
+ if not self._has_context_map():
1039
+ return False
1040
+ path = (self._addon_context_map_var.get() or "").strip()
1041
+ try:
1042
+ meta = remapper_core.load_context_map_meta(path)
1043
+ except Exception:
1044
+ return False
1045
+ if not isinstance(meta, dict):
1046
+ return False
1047
+ layout_template = meta.get("layout_template")
1048
+ if isinstance(layout_template, str) and layout_template.strip():
1049
+ return True
1050
+ entries = meta.get("layout_entries") or meta.get("layout_fields")
1051
+ return isinstance(entries, list) and len(entries) > 0
1052
+
1053
+ def _current_slicepack_suffix(self) -> str:
1054
+ _, _, slicepack_suffix, _ = self._resolve_layout_sources(reco_id=self._current_reco_id)
1055
+ return slicepack_suffix or ""
1056
+
1057
+ def _config_layout_templates(self) -> list[str]:
1058
+ templates: list[str] = []
1059
+ config = config_core.load_config(root=None) or {}
1060
+ output_cfg = config.get("output", {})
1061
+ if isinstance(output_cfg, dict):
1062
+ raw_list = output_cfg.get("layout_templates", [])
1063
+ if isinstance(raw_list, list):
1064
+ for item in raw_list:
1065
+ if isinstance(item, str) and item.strip():
1066
+ templates.append(item)
1067
+ elif isinstance(item, dict):
1068
+ value = item.get("template") or item.get("value")
1069
+ if isinstance(value, str) and value.strip():
1070
+ templates.append(value)
1071
+ default_template = config_core.layout_template(root=None)
1072
+ if isinstance(default_template, str) and default_template.strip():
1073
+ if default_template not in templates:
1074
+ templates.insert(0, default_template)
1075
+ return templates
1076
+
1077
+ def _refresh_layout_spec_selectors(self) -> None:
1078
+ return
1079
+
1080
+ def _refresh_layout_spec_status(self) -> None:
1081
+ return
1082
+
1083
+ def _browse_layout_spec_file(self, *, kind: str) -> None:
1084
+ return
1085
+
1086
+ def _layout_builtin_info_spec_paths(self) -> tuple[Optional[str], Optional[str]]:
1087
+ try:
1088
+ module = importlib.import_module("brkraw.apps.loader.info.scan")
1089
+ scan_yaml = str(Path(cast(Any, module).__file__).with_name("scan.yaml"))
1090
+ except Exception:
1091
+ scan_yaml = None
1092
+ try:
1093
+ module = importlib.import_module("brkraw.apps.loader.info.study")
1094
+ study_yaml = str(Path(cast(Any, module).__file__).with_name("study.yaml"))
1095
+ except Exception:
1096
+ study_yaml = None
1097
+ return study_yaml, scan_yaml
1098
+
1099
+ def _layout_info_spec_path(self) -> Optional[str]:
1100
+ file_path = (self._layout_info_spec_file_var.get() or "").strip()
1101
+ if file_path:
1102
+ return file_path
1103
+ return None
1104
+
1105
+ def _layout_metadata_spec_path(self) -> Optional[str]:
1106
+ file_path = (self._layout_metadata_spec_file_var.get() or "").strip()
1107
+ if file_path:
1108
+ return file_path
1109
+ return None
1110
+
1111
+ def _refresh_layout_keys(self) -> None:
1112
+ if self._layout_key_listbox is None or self._loader is None or self._scan is None:
1113
+ return
1114
+ scan_id = getattr(self._scan, "scan_id", None)
1115
+ if scan_id is None:
1116
+ return
1117
+
1118
+ info_spec = self._layout_info_spec_path()
1119
+ metadata_spec = self._layout_metadata_spec_path()
1120
+ source_mode = self._layout_source_mode()
1121
+ signature = (
1122
+ scan_id,
1123
+ self._current_reco_id,
1124
+ info_spec or "Default",
1125
+ (self._layout_info_spec_file_var.get() or "").strip(),
1126
+ metadata_spec or "None",
1127
+ (self._layout_metadata_spec_file_var.get() or "").strip(),
1128
+ source_mode,
1129
+ bool(self._layout_auto_var.get()),
1130
+ (self._layout_template_var.get() or "").strip(),
1131
+ (self._addon_context_map_var.get() or "").strip(),
1132
+ )
1133
+ if self._layout_key_source_signature is None:
1134
+ self._layout_key_source_signature = signature
1135
+ elif self._layout_key_source_signature != signature:
1136
+ self._layout_key_source_signature = signature
1137
+ if self._layout_template_enabled() and not (self._layout_template_var.get() or "").strip():
1138
+ self._layout_template_var.set(config_core.layout_template(root=None) or "")
1139
+
1140
+ context_map = self._current_context_map_path()
1141
+ try:
1142
+ info = layout_core.load_layout_info(
1143
+ self._loader,
1144
+ scan_id,
1145
+ context_map=context_map,
1146
+ root=resolve_root(None),
1147
+ reco_id=self._current_reco_id,
1148
+ override_info_spec=info_spec,
1149
+ override_metadata_spec=metadata_spec,
1150
+ )
1151
+ except Exception:
1152
+ info = {}
1153
+
1154
+ # BrkRaw built-in layout keys should always be available in the picker.
1155
+ # Keep this list focused on scalar-friendly tags (for filenames).
1156
+ keys = sorted(set(self._flatten_keys(info)) | {"scan_id", "reco_id", "Counter"})
1157
+ previous_state: Optional[str] = None
1158
+ try:
1159
+ previous_state = str(self._layout_key_listbox.cget("state"))
1160
+ if previous_state == str(tk.DISABLED):
1161
+ self._layout_key_listbox.configure(state=tk.NORMAL)
1162
+ except Exception:
1163
+ previous_state = None
1164
+
1165
+ self._layout_key_listbox.delete(0, tk.END)
1166
+ for key in keys:
1167
+ self._layout_key_listbox.insert(tk.END, key)
1168
+
1169
+ if previous_state == str(tk.DISABLED):
1170
+ try:
1171
+ self._layout_key_listbox.configure(state=tk.DISABLED)
1172
+ except Exception:
1173
+ pass
1174
+
1175
+ if hasattr(self, "_layout_keys_title"):
1176
+ study_yaml, scan_yaml = self._layout_builtin_info_spec_paths()
1177
+ if info_spec is None:
1178
+ src = "Default"
1179
+ if scan_yaml and study_yaml:
1180
+ src = "Default (study.yaml + scan.yaml)"
1181
+ else:
1182
+ src = Path(info_spec).name
1183
+ self._layout_keys_title.set(f"Key (click to add) — {len(keys)} keys | {src}")
1184
+
1185
+ def _flatten_keys(self, obj: Any, prefix: str = "") -> Iterable[str]:
1186
+ if isinstance(obj, Mapping):
1187
+ for k, v in obj.items():
1188
+ key = str(k)
1189
+ path = f"{prefix}.{key}" if prefix else key
1190
+ if isinstance(v, Mapping):
1191
+ yield from self._flatten_keys(v, path)
1192
+ elif isinstance(v, (list, tuple)):
1193
+ continue
1194
+ else:
1195
+ yield path
1196
+
1197
+ def _selected_layout_key(self) -> Optional[str]:
1198
+ if self._layout_key_listbox is None:
1199
+ return None
1200
+ selection = self._layout_key_listbox.curselection()
1201
+ if not selection:
1202
+ return None
1203
+ key = str(self._layout_key_listbox.get(int(selection[0])))
1204
+ if not key:
1205
+ return None
1206
+ return key
1207
+
1208
+ def _add_selected_layout_key(self) -> None:
1209
+ key = self._selected_layout_key()
1210
+ if not key:
1211
+ return
1212
+ if not self._layout_template_enabled():
1213
+ self._status_var.set("Template is disabled for the current layout source.")
1214
+ return
1215
+ current = self._layout_template_var.get() or ""
1216
+ self._layout_template_var.set(f"{current}{{{key}}}")
1217
+
1218
+ def _remove_selected_layout_key(self) -> None:
1219
+ key = self._selected_layout_key()
1220
+ if not key:
1221
+ return
1222
+ if not self._layout_template_enabled():
1223
+ self._status_var.set("Template is disabled for the current layout source.")
1224
+ return
1225
+ token = f"{{{key}}}"
1226
+ current = self._layout_template_var.get() or ""
1227
+ idx = current.rfind(token)
1228
+ if idx < 0:
1229
+ return
1230
+ self._layout_template_var.set(current[:idx] + current[idx + len(token) :])
1231
+
1232
+ def _on_layout_key_double_click(self, *_: object) -> None:
1233
+ # Selection only. Use +/- buttons to edit the template.
1234
+ return
1235
+
1236
+ def _on_layout_key_click(self, *_: object) -> None:
1237
+ # Selection only. Use +/- buttons to edit the template.
1238
+ return
1239
+
1240
+ def _on_layout_key_mouse_down(self, *_: object) -> Optional[str]:
1241
+ if not self._layout_template_enabled():
1242
+ return "break"
1243
+ return None
1244
+
1245
+ def _current_context_map_path(self) -> Optional[str]:
1246
+ if not self._has_context_map():
1247
+ return None
1248
+ path = (self._addon_context_map_var.get() or "").strip()
1249
+ mode = self._layout_source_mode()
1250
+ if mode == "Context map":
1251
+ return path
1252
+ if mode == "auto":
1253
+ gui_template = (self._layout_template_var.get() or "").strip()
1254
+ if gui_template:
1255
+ return None
1256
+ return path
1257
+ return None
1258
+
1259
+ def _resolve_layout_sources(
1260
+ self,
1261
+ *,
1262
+ reco_id: Optional[int],
1263
+ ) -> tuple[Optional[str], Optional[list], str, Optional[str]]:
1264
+ root = resolve_root(None)
1265
+ layout_entries = config_core.layout_entries(root=root)
1266
+ layout_template = config_core.layout_template(root=root)
1267
+ slicepack_suffix = config_core.output_slicepack_suffix(root=root)
1268
+
1269
+ mode = self._layout_source_mode()
1270
+ if mode == "auto":
1271
+ gui_template = (self._layout_template_manual or "").strip()
1272
+ else:
1273
+ gui_template = (self._layout_template_var.get() or "").strip()
1274
+ gui_suffix = (self._slicepack_suffix_var.get() or "").strip()
1275
+ context_map_path: Optional[str] = None
1276
+
1277
+ if mode == "GUI template":
1278
+ if gui_template:
1279
+ layout_template = self._render_template_with_context(gui_template, reco_id=reco_id)
1280
+ layout_entries = None
1281
+ if gui_suffix:
1282
+ slicepack_suffix = gui_suffix
1283
+ return layout_template, layout_entries, slicepack_suffix, None
1284
+
1285
+ if mode == "Context map":
1286
+ context_map_path = self._current_context_map_path()
1287
+ if context_map_path:
1288
+ try:
1289
+ meta = remapper_core.load_context_map_meta(context_map_path)
1290
+ except Exception:
1291
+ meta = {}
1292
+ if isinstance(meta, dict):
1293
+ map_suffix = meta.get("slicepack_suffix")
1294
+ map_template = meta.get("layout_template")
1295
+ map_entries = meta.get("layout_entries") or meta.get("layout_fields")
1296
+ if isinstance(map_suffix, str) and map_suffix.strip():
1297
+ slicepack_suffix = map_suffix
1298
+ if isinstance(map_template, str) and map_template.strip():
1299
+ layout_template = map_template
1300
+ layout_entries = None
1301
+ elif isinstance(map_entries, list):
1302
+ layout_entries = map_entries
1303
+ layout_template = None
1304
+ return layout_template, layout_entries, slicepack_suffix, context_map_path
1305
+
1306
+ if mode == "Config":
1307
+ return layout_template, layout_entries, slicepack_suffix, None
1308
+
1309
+ if gui_template:
1310
+ layout_template = self._render_template_with_context(gui_template, reco_id=reco_id)
1311
+ layout_entries = None
1312
+ if gui_suffix:
1313
+ slicepack_suffix = gui_suffix
1314
+ return layout_template, layout_entries, slicepack_suffix, None
1315
+
1316
+ context_map_path = self._current_context_map_path()
1317
+ if context_map_path:
1318
+ try:
1319
+ meta = remapper_core.load_context_map_meta(context_map_path)
1320
+ except Exception:
1321
+ meta = {}
1322
+ if isinstance(meta, dict):
1323
+ map_suffix = meta.get("slicepack_suffix")
1324
+ map_template = meta.get("layout_template")
1325
+ map_entries = meta.get("layout_entries") or meta.get("layout_fields")
1326
+ if isinstance(map_suffix, str) and map_suffix.strip():
1327
+ slicepack_suffix = map_suffix
1328
+ if isinstance(map_template, str) and map_template.strip():
1329
+ layout_template = map_template
1330
+ layout_entries = None
1331
+ elif isinstance(map_entries, list):
1332
+ layout_entries = map_entries
1333
+ layout_template = None
1334
+ return layout_template, layout_entries, slicepack_suffix, context_map_path
1335
+
1336
+ return layout_template, layout_entries, slicepack_suffix, None
1337
+
1338
+ def _layout_entries_active(self) -> bool:
1339
+ layout_template, layout_entries, _, _ = self._resolve_layout_sources(reco_id=self._current_reco_id)
1340
+ return layout_template is None and bool(layout_entries)
1341
+
1342
+ def _convert_subject_orientation(self) -> tuple[Optional[SubjectType], Optional[SubjectPose]]:
1343
+ if self._convert_space_var.get() != "subject_ras":
1344
+ return None, None
1345
+ if self._convert_use_viewer_pose_var.get():
1346
+ subject_type = self._cast_subject_type((self._subject_type_var.get() or "").strip())
1347
+ subject_pose = self._cast_subject_pose(
1348
+ f"{(self._pose_primary_var.get() or '').strip()}_{(self._pose_secondary_var.get() or '').strip()}"
1349
+ )
1350
+ return subject_type, subject_pose
1351
+
1352
+ subject_type = self._cast_subject_type((self._convert_subject_type_var.get() or "").strip())
1353
+ subject_pose = self._cast_subject_pose(
1354
+ f"{(self._convert_pose_primary_var.get() or '').strip()}_{(self._convert_pose_secondary_var.get() or '').strip()}"
1355
+ )
1356
+ return subject_type, subject_pose
1357
+
1358
+ def _convert_flip_settings(self) -> tuple[bool, bool, bool]:
1359
+ if self._convert_space_var.get() != "subject_ras":
1360
+ return False, False, False
1361
+ if self._convert_use_viewer_pose_var.get():
1362
+ return (
1363
+ bool(self._affine_flip_x_var.get()),
1364
+ bool(self._affine_flip_y_var.get()),
1365
+ bool(self._affine_flip_z_var.get()),
1366
+ )
1367
+ return (
1368
+ bool(self._convert_flip_x_var.get()),
1369
+ bool(self._convert_flip_y_var.get()),
1370
+ bool(self._convert_flip_z_var.get()),
1371
+ )
1372
+
1373
+ def _estimate_slicepack_count(self) -> int:
1374
+ if self._scan is None or self._current_reco_id is None:
1375
+ return 0
1376
+ try:
1377
+ dataobj = self._scan.get_dataobj(reco_id=self._current_reco_id)
1378
+ except Exception:
1379
+ return 0
1380
+ if isinstance(dataobj, tuple):
1381
+ return len(dataobj)
1382
+ return 1 if dataobj is not None else 0
1383
+
1384
+ _COUNTER_SENTINEL = 987654321
1385
+ _COUNTER_PLACEHOLDER = "<N>"
1386
+
1387
+ def _planned_output_paths(self, *, preview: bool, count: Optional[int] = None) -> list[Path]:
1388
+ if self._scan is None or self._current_reco_id is None:
1389
+ return []
1390
+ scan_id = getattr(self._scan, "scan_id", None)
1391
+ if scan_id is None:
1392
+ return []
1393
+
1394
+ output_dir = self._output_dir_var.get().strip() or "output"
1395
+ output_path = Path(output_dir)
1396
+
1397
+ if count is None:
1398
+ count = self._estimate_slicepack_count()
1399
+ if int(count) <= 0:
1400
+ return []
1401
+ count = int(count)
1402
+
1403
+ layout_template, layout_entries, slicepack_suffix, context_map = self._resolve_layout_sources(
1404
+ reco_id=self._current_reco_id
1405
+ )
1406
+
1407
+ info_spec_path = self._layout_info_spec_path()
1408
+ metadata_spec_path = self._layout_metadata_spec_path()
1409
+ root = resolve_root(None)
1410
+
1411
+ try:
1412
+ info = layout_core.load_layout_info(
1413
+ self._loader,
1414
+ scan_id,
1415
+ context_map=context_map,
1416
+ root=root,
1417
+ reco_id=self._current_reco_id,
1418
+ override_info_spec=info_spec_path,
1419
+ override_metadata_spec=metadata_spec_path,
1420
+ )
1421
+ except Exception:
1422
+ info = {}
1423
+
1424
+ counter_enabled = self._uses_counter_tag(
1425
+ layout_template=layout_template,
1426
+ layout_entries=layout_entries,
1427
+ )
1428
+ counter_preview: Optional[int] = self._COUNTER_SENTINEL if (preview and counter_enabled) else None
1429
+
1430
+ reserved: set[Path] = set()
1431
+ base_name_base: Optional[str] = None
1432
+ for attempt in range(1, 1000):
1433
+ counter = attempt if (counter_enabled and not preview) else counter_preview
1434
+ try:
1435
+ base_name = layout_core.render_layout(
1436
+ self._loader,
1437
+ scan_id,
1438
+ layout_entries=layout_entries,
1439
+ layout_template=layout_template,
1440
+ context_map=context_map,
1441
+ root=root,
1442
+ reco_id=self._current_reco_id,
1443
+ counter=counter,
1444
+ override_info_spec=info_spec_path,
1445
+ override_metadata_spec=metadata_spec_path,
1446
+ )
1447
+ except Exception:
1448
+ base_name = f"scan-{scan_id}"
1449
+ base_name = str(base_name)
1450
+
1451
+ if base_name_base is None:
1452
+ base_name_base = base_name
1453
+
1454
+ if not counter_enabled and attempt > 1:
1455
+ base_name = f"{base_name_base}_{attempt}"
1456
+
1457
+ if count > 1:
1458
+ try:
1459
+ suffixes = layout_core.render_slicepack_suffixes(
1460
+ info,
1461
+ count=count,
1462
+ template=slicepack_suffix,
1463
+ counter=counter,
1464
+ )
1465
+ except Exception:
1466
+ suffixes = [f"_slpack{i + 1}" for i in range(count)]
1467
+ else:
1468
+ suffixes = [""]
1469
+
1470
+ if preview and counter_enabled:
1471
+ base_name = base_name.replace(str(self._COUNTER_SENTINEL), self._COUNTER_PLACEHOLDER)
1472
+ suffixes = [
1473
+ suffix.replace(str(self._COUNTER_SENTINEL), self._COUNTER_PLACEHOLDER) for suffix in suffixes
1474
+ ]
1475
+
1476
+ paths: list[Path] = []
1477
+ for idx in range(count):
1478
+ suffix = suffixes[idx] if idx < len(suffixes) else f"_slpack{idx + 1}"
1479
+ filename = f"{base_name}{suffix}.nii.gz"
1480
+ paths.append(output_path / filename)
1481
+
1482
+ if preview:
1483
+ return paths
1484
+ if self._paths_collide(paths, reserved):
1485
+ continue
1486
+ return paths
1487
+ return []
1488
+
1489
+ def _render_template_with_context(self, template: str, *, reco_id: Optional[int]) -> str:
1490
+ value = "" if reco_id is None else str(int(reco_id))
1491
+ for key in ("reco_id", "recoid", "RecoID"):
1492
+ template = template.replace(f"{{{key}}}", value)
1493
+ return template
1494
+
1495
+ def _uses_counter_tag(self, *, layout_template: Optional[str], layout_entries: Optional[list]) -> bool:
1496
+ if isinstance(layout_template, str) and ("{Counter}" in layout_template or "{counter}" in layout_template):
1497
+ return True
1498
+ for entry in layout_entries or []:
1499
+ if not isinstance(entry, Mapping):
1500
+ continue
1501
+ key = entry.get("key")
1502
+ if isinstance(key, str) and key.strip() in {"Counter", "counter"}:
1503
+ return True
1504
+ return False
1505
+
1506
+ @staticmethod
1507
+ def _paths_collide(paths: list[Path], reserved: set[Path]) -> bool:
1508
+ if len(set(paths)) != len(paths):
1509
+ return True
1510
+ for path in paths:
1511
+ if path in reserved:
1512
+ return True
1513
+ try:
1514
+ if path.exists():
1515
+ return True
1516
+ except OSError:
1517
+ return True
1518
+ reserved.update(paths)
1519
+ return False
1520
+
1521
+ def _preview_convert_outputs(self) -> None:
1522
+ if self._scan is None or self._current_reco_id is None:
1523
+ self._set_convert_settings("No scan/reco selected.")
1524
+ self._set_convert_preview("")
1525
+ return
1526
+ scan_id = getattr(self._scan, "scan_id", None)
1527
+ if scan_id is None:
1528
+ self._set_convert_settings("Scan id unavailable.")
1529
+ self._set_convert_preview("")
1530
+ return
1531
+
1532
+ space = self._convert_space_var.get()
1533
+ subject_type, subject_pose = self._convert_subject_orientation()
1534
+ flip_x, flip_y, flip_z = self._convert_flip_settings()
1535
+
1536
+ planned = self._planned_output_paths(preview=True)
1537
+ if not planned:
1538
+ self._set_convert_settings("No output planned (missing data or reco).")
1539
+ self._set_convert_preview("")
1540
+ return
1541
+
1542
+ meta_text = self._preview_metadata_yaml(scan_id)
1543
+ self._set_convert_settings(meta_text)
1544
+
1545
+ preview_list = list(planned)
1546
+ if self._convert_sidecar_var.get():
1547
+ preview_list.extend(self._planned_sidecar_paths(planned))
1548
+ self._set_convert_preview("\n".join(str(p) for p in preview_list))
1549
+
1550
+ def _preview_metadata_yaml(self, scan_id: int) -> str:
1551
+ layout_template, layout_entries, _, context_map = self._resolve_layout_sources(reco_id=self._current_reco_id)
1552
+ info_spec_path = self._layout_info_spec_path()
1553
+ metadata_spec_path = self._layout_metadata_spec_path()
1554
+ try:
1555
+ info = layout_core.load_layout_info(
1556
+ self._loader,
1557
+ scan_id,
1558
+ context_map=context_map,
1559
+ root=resolve_root(None),
1560
+ reco_id=self._current_reco_id,
1561
+ override_info_spec=info_spec_path,
1562
+ override_metadata_spec=metadata_spec_path,
1563
+ )
1564
+ except Exception as exc:
1565
+ return f"Metadata preview failed:\n{exc}"
1566
+ return yaml.safe_dump(info, sort_keys=False)
1567
+
1568
+ def _planned_sidecar_paths(self, planned: list[Path]) -> list[Path]:
1569
+ suffix = ".json" if self._convert_sidecar_format_var.get() == "json" else ".yaml"
1570
+ sidecars: list[Path] = []
1571
+ for path in planned:
1572
+ sidecar = path.with_suffix(suffix)
1573
+ if path.name.endswith(".nii.gz"):
1574
+ sidecar = path.with_name(path.name[:-7] + suffix)
1575
+ sidecars.append(sidecar)
1576
+ return sidecars
1577
+
1578
+ def _convert_current_scan(self) -> None:
1579
+ if self._loader is None or self._scan is None or self._current_reco_id is None:
1580
+ self._status_var.set("No scan selected.")
1581
+ return
1582
+ scan_id = getattr(self._scan, "scan_id", None)
1583
+ if scan_id is None:
1584
+ self._status_var.set("Scan id unavailable.")
1585
+ return
1586
+
1587
+ subject_type, subject_pose = self._convert_subject_orientation()
1588
+ space = self._convert_space_var.get()
1589
+
1590
+ flip_x, flip_y, flip_z = self._convert_flip_settings()
1591
+ try:
1592
+ hook_args = self._collect_convert_hook_args()
1593
+ logger.debug("Calling loader.convert hook_args_by_name=%s", hook_args)
1594
+ nii = self._loader.convert(
1595
+ scan_id,
1596
+ reco_id=self._current_reco_id,
1597
+ format="nifti",
1598
+ space=cast(Any, space),
1599
+ override_subject_type=subject_type,
1600
+ override_subject_pose=subject_pose,
1601
+ hook_args_by_name=hook_args,
1602
+ )
1603
+ except Exception as exc:
1604
+ self._set_convert_settings(f"Convert failed: {exc}")
1605
+ self._status_var.set("Conversion failed.")
1606
+ return
1607
+
1608
+ if nii is None:
1609
+ self._status_var.set("No NIfTI output generated.")
1610
+ return
1611
+ nii_list = list(nii) if isinstance(nii, tuple) else [nii]
1612
+ planned = self._planned_output_paths(preview=False, count=len(nii_list))
1613
+ if not planned:
1614
+ self._status_var.set("No output planned.")
1615
+ return
1616
+ if len(nii_list) != len(planned):
1617
+ planned = planned[: len(nii_list)]
1618
+
1619
+ output_path = planned[0].parent
1620
+ output_path.mkdir(parents=True, exist_ok=True)
1621
+
1622
+ sidecar_meta = self._build_sidecar_metadata(scan_id, self._current_reco_id)
1623
+
1624
+ for dest, img in zip(planned, nii_list):
1625
+ if flip_x or flip_y or flip_z:
1626
+ try:
1627
+ affine = affine_resolver.flip_affine(
1628
+ img.affine,
1629
+ flip_x=flip_x,
1630
+ flip_y=flip_y,
1631
+ flip_z=flip_z,
1632
+ )
1633
+ img.set_qform(affine, code=int(img.header.get("qform_code", 1) or 1))
1634
+ img.set_sform(affine, code=int(img.header.get("sform_code", 1) or 1))
1635
+ except Exception:
1636
+ pass
1637
+ try:
1638
+ img.to_filename(str(dest))
1639
+ except Exception as exc:
1640
+ self._set_convert_preview(f"Save failed: {exc}\n\nPath: {dest}")
1641
+ self._status_var.set("Save failed.")
1642
+ return
1643
+ if sidecar_meta:
1644
+ try:
1645
+ self._write_sidecar(dest, sidecar_meta)
1646
+ except Exception as exc:
1647
+ self._set_convert_preview(f"Sidecar failed: {exc}\n\nPath: {dest}")
1648
+ self._status_var.set("Sidecar failed.")
1649
+ return
1650
+ self._status_var.set(f"Saved {len(nii_list)} file(s) to {output_path}")
1651
+ logger.info("Saved %d file(s) to %s", len(nii_list), output_path)
1652
+ try:
1653
+ from tkinter import messagebox
1654
+
1655
+ messagebox.showinfo("Convert", f"Saved {len(nii_list)} file(s).\n{output_path}")
1656
+ except Exception:
1657
+ pass
1658
+
1659
+ def _build_sidecar_metadata(self, scan_id: int, reco_id: Optional[int]) -> Optional[Mapping[str, Any]]:
1660
+ if not bool(self._convert_sidecar_var.get()):
1661
+ return None
1662
+ get_metadata = getattr(self._loader, "get_metadata", None)
1663
+ if not callable(get_metadata):
1664
+ self._status_var.set("Metadata sidecar unavailable.")
1665
+ return None
1666
+ metadata_spec = self._layout_metadata_spec_path()
1667
+ try:
1668
+ meta = get_metadata(
1669
+ scan_id,
1670
+ reco_id=reco_id,
1671
+ spec=metadata_spec if metadata_spec else None,
1672
+ context_map=self._current_context_map_path(),
1673
+ )
1674
+ except Exception:
1675
+ return None
1676
+ if isinstance(meta, tuple) and meta:
1677
+ meta = meta[0]
1678
+ return meta if isinstance(meta, Mapping) else None
1679
+
1680
+ def _write_sidecar(self, path: Path, meta: Mapping[str, Any]) -> None:
1681
+ payload = dict(meta)
1682
+ suffix = ".json" if self._convert_sidecar_format_var.get() == "json" else ".yaml"
1683
+ sidecar = path.with_suffix(suffix)
1684
+ if path.name.endswith(".nii.gz"):
1685
+ sidecar = path.with_name(path.name[:-7] + suffix)
1686
+ if suffix == ".yaml":
1687
+ sidecar.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8")
1688
+ else:
1689
+ sidecar.write_text(json.dumps(payload, indent=2, sort_keys=False), encoding="utf-8")