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.
- brkraw_viewer/__init__.py +4 -0
- brkraw_viewer/apps/__init__.py +0 -0
- brkraw_viewer/apps/config.py +90 -0
- brkraw_viewer/apps/convert.py +1689 -0
- brkraw_viewer/apps/hooks.py +36 -0
- brkraw_viewer/apps/viewer.py +5316 -0
- brkraw_viewer/assets/icon.ico +0 -0
- brkraw_viewer/assets/icon.png +0 -0
- brkraw_viewer/frames/__init__.py +2 -0
- brkraw_viewer/frames/params_panel.py +80 -0
- brkraw_viewer/frames/viewer_canvas.py +340 -0
- brkraw_viewer/frames/viewer_config.py +101 -0
- brkraw_viewer/plugin.py +125 -0
- brkraw_viewer/registry.py +258 -0
- brkraw_viewer/snippets/context_map/basic.yaml +4 -0
- brkraw_viewer/snippets/context_map/enum-map.yaml +5 -0
- brkraw_viewer/snippets/rule/basic.yaml +10 -0
- brkraw_viewer/snippets/rule/when-contains.yaml +10 -0
- brkraw_viewer/snippets/spec/basic.yaml +5 -0
- brkraw_viewer/snippets/spec/list-source.yaml +5 -0
- brkraw_viewer/snippets/spec/with-default.yaml +5 -0
- brkraw_viewer/utils/__init__.py +2 -0
- brkraw_viewer/utils/orientation.py +17 -0
- brkraw_viewer-0.2.5.dist-info/METADATA +170 -0
- brkraw_viewer-0.2.5.dist-info/RECORD +28 -0
- brkraw_viewer-0.2.5.dist-info/WHEEL +5 -0
- brkraw_viewer-0.2.5.dist-info/entry_points.txt +2 -0
- brkraw_viewer-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -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")
|