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
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tkinter as tk
|
|
4
|
+
from tkinter import ttk
|
|
5
|
+
from typing import Any, Dict, Iterable, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ParamsPanel(ttk.Frame):
|
|
9
|
+
def __init__(self, parent: tk.Widget) -> None:
|
|
10
|
+
super().__init__(parent)
|
|
11
|
+
self.columnconfigure(0, weight=1)
|
|
12
|
+
self.rowconfigure(1, weight=1)
|
|
13
|
+
|
|
14
|
+
self._search_var = tk.StringVar(value="")
|
|
15
|
+
|
|
16
|
+
search_bar = ttk.Frame(self)
|
|
17
|
+
search_bar.grid(row=0, column=0, sticky="ew", pady=(0, 6))
|
|
18
|
+
search_bar.columnconfigure(1, weight=1)
|
|
19
|
+
|
|
20
|
+
ttk.Label(search_bar, text="Search").grid(row=0, column=0, sticky="w")
|
|
21
|
+
entry = ttk.Entry(search_bar, textvariable=self._search_var)
|
|
22
|
+
entry.grid(row=0, column=1, sticky="ew", padx=(6, 0))
|
|
23
|
+
entry.bind("<Return>", self._on_search)
|
|
24
|
+
ttk.Button(search_bar, text="Go", command=self._on_search).grid(row=0, column=2, padx=(6, 0))
|
|
25
|
+
|
|
26
|
+
self._listbox = tk.Listbox(self, exportselection=False, height=10)
|
|
27
|
+
self._listbox.grid(row=1, column=0, sticky="nsew")
|
|
28
|
+
self._listbox.bind("<<ListboxSelect>>", self._on_select)
|
|
29
|
+
|
|
30
|
+
self._text = tk.Text(self, wrap="word", height=12)
|
|
31
|
+
self._text.grid(row=2, column=0, sticky="nsew", pady=(6, 0))
|
|
32
|
+
self._text.configure(state=tk.DISABLED)
|
|
33
|
+
|
|
34
|
+
self._params: Dict[str, Dict[str, Any]] = {}
|
|
35
|
+
self._param_keys: list[str] = []
|
|
36
|
+
|
|
37
|
+
def set_params(self, params: Dict[str, Dict[str, Any]]) -> None:
|
|
38
|
+
self._params = params
|
|
39
|
+
self._param_keys = sorted(params.keys())
|
|
40
|
+
self._refresh_list(self._param_keys)
|
|
41
|
+
self._set_text("Select a parameter file to view contents.")
|
|
42
|
+
|
|
43
|
+
def clear(self) -> None:
|
|
44
|
+
self._params = {}
|
|
45
|
+
self._param_keys = []
|
|
46
|
+
self._refresh_list([])
|
|
47
|
+
self._set_text("No parameter data.")
|
|
48
|
+
|
|
49
|
+
def _refresh_list(self, items: Iterable[str]) -> None:
|
|
50
|
+
self._listbox.delete(0, tk.END)
|
|
51
|
+
for item in items:
|
|
52
|
+
self._listbox.insert(tk.END, item)
|
|
53
|
+
|
|
54
|
+
def _set_text(self, text: str) -> None:
|
|
55
|
+
self._text.configure(state=tk.NORMAL)
|
|
56
|
+
self._text.delete("1.0", tk.END)
|
|
57
|
+
self._text.insert(tk.END, text)
|
|
58
|
+
self._text.configure(state=tk.DISABLED)
|
|
59
|
+
|
|
60
|
+
def _on_search(self, *_: object) -> None:
|
|
61
|
+
query = self._search_var.get().strip().lower()
|
|
62
|
+
if not query:
|
|
63
|
+
self._refresh_list(self._param_keys)
|
|
64
|
+
return
|
|
65
|
+
filtered = [key for key in self._param_keys if query in key.lower()]
|
|
66
|
+
self._refresh_list(filtered)
|
|
67
|
+
|
|
68
|
+
def _on_select(self, *_: object) -> None:
|
|
69
|
+
selection = self._listbox.curselection()
|
|
70
|
+
if not selection:
|
|
71
|
+
return
|
|
72
|
+
key = self._listbox.get(int(selection[0]))
|
|
73
|
+
data = self._params.get(key)
|
|
74
|
+
if not data:
|
|
75
|
+
self._set_text("No data.")
|
|
76
|
+
return
|
|
77
|
+
lines = [f"[{key}]"]
|
|
78
|
+
for k in sorted(data.keys()):
|
|
79
|
+
lines.append(f"{k} = {data[k]}")
|
|
80
|
+
self._set_text("\n".join(lines))
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tkinter as tk
|
|
4
|
+
from tkinter import ttk
|
|
5
|
+
from typing import Callable, Dict, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from PIL import Image, ImageTk
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
ClickCallback = Callable[[str, int, int], None]
|
|
12
|
+
ZoomCallback = Callable[[int], None]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OrthogonalCanvas(ttk.Frame):
|
|
16
|
+
def __init__(self, parent: tk.Widget) -> None:
|
|
17
|
+
super().__init__(parent)
|
|
18
|
+
self.columnconfigure(0, weight=1)
|
|
19
|
+
self.columnconfigure(1, weight=1)
|
|
20
|
+
self.columnconfigure(2, weight=1)
|
|
21
|
+
self.rowconfigure(0, weight=1)
|
|
22
|
+
|
|
23
|
+
self._canvas_xz = tk.Canvas(self, background="#111111", highlightthickness=0)
|
|
24
|
+
self._canvas_xy = tk.Canvas(self, background="#111111", highlightthickness=0)
|
|
25
|
+
self._canvas_zy = tk.Canvas(self, background="#111111", highlightthickness=0)
|
|
26
|
+
|
|
27
|
+
self._canvas_xz.grid(row=0, column=0, sticky="nsew", padx=(0, 4))
|
|
28
|
+
self._canvas_xy.grid(row=0, column=1, sticky="nsew", padx=(0, 4))
|
|
29
|
+
self._canvas_zy.grid(row=0, column=2, sticky="nsew")
|
|
30
|
+
|
|
31
|
+
self._canvas_map = {"xz": self._canvas_xz, "xy": self._canvas_xy, "zy": self._canvas_zy}
|
|
32
|
+
self._canvas_image_id: Dict[str, Optional[int]] = {"zy": None, "xy": None, "xz": None}
|
|
33
|
+
self._canvas_text_id: Dict[str, Optional[int]] = {"zy": None, "xy": None, "xz": None}
|
|
34
|
+
self._tk_images: Dict[str, Optional[ImageTk.PhotoImage]] = {"zy": None, "xy": None, "xz": None}
|
|
35
|
+
self._markers: Dict[str, list[int]] = {"zy": [], "xy": [], "xz": []}
|
|
36
|
+
self._boxes: Dict[str, list[int]] = {"zy": [], "xy": [], "xz": []}
|
|
37
|
+
self._click_callback: Optional[ClickCallback] = None
|
|
38
|
+
self._zoom_callback: Optional[ZoomCallback] = None
|
|
39
|
+
|
|
40
|
+
self._last_views: Optional[Dict[str, Tuple[np.ndarray, Tuple[float, float]]]] = None
|
|
41
|
+
self._last_titles: Optional[Dict[str, str]] = None
|
|
42
|
+
self._last_crosshair: Optional[Dict[str, Tuple[int, int]]] = None
|
|
43
|
+
self._last_show_crosshair: bool = False
|
|
44
|
+
self._last_message: Optional[str] = None
|
|
45
|
+
self._last_is_error = False
|
|
46
|
+
self._render_state: Dict[str, Tuple[int, int, int, int, int, int]] = {}
|
|
47
|
+
|
|
48
|
+
for key, canvas in self._canvas_map.items():
|
|
49
|
+
canvas.bind("<Configure>", self._on_resize)
|
|
50
|
+
canvas.bind("<Button-1>", lambda event, view=key: self._on_click(view, event))
|
|
51
|
+
canvas.bind("<MouseWheel>", self._on_mousewheel)
|
|
52
|
+
canvas.bind("<Button-4>", self._on_mousewheel)
|
|
53
|
+
canvas.bind("<Button-5>", self._on_mousewheel)
|
|
54
|
+
|
|
55
|
+
def set_click_callback(self, callback: Optional[ClickCallback]) -> None:
|
|
56
|
+
self._click_callback = callback
|
|
57
|
+
|
|
58
|
+
def set_zoom_callback(self, callback: Optional[ZoomCallback]) -> None:
|
|
59
|
+
self._zoom_callback = callback
|
|
60
|
+
|
|
61
|
+
def clear_markers(self) -> None:
|
|
62
|
+
for key, canvas in self._canvas_map.items():
|
|
63
|
+
for marker_id in self._markers[key]:
|
|
64
|
+
try:
|
|
65
|
+
canvas.delete(marker_id)
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
self._markers[key] = []
|
|
69
|
+
|
|
70
|
+
def clear_boxes(self) -> None:
|
|
71
|
+
for key, canvas in self._canvas_map.items():
|
|
72
|
+
for box_id in self._boxes[key]:
|
|
73
|
+
try:
|
|
74
|
+
canvas.delete(box_id)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
self._boxes[key] = []
|
|
78
|
+
|
|
79
|
+
def add_marker(self, view: str, row: int, col: int, color: str) -> None:
|
|
80
|
+
state = self._render_state.get(view)
|
|
81
|
+
if state is None:
|
|
82
|
+
return
|
|
83
|
+
img_h, img_w, offset_x, offset_y, target_w, target_h = state
|
|
84
|
+
if img_h <= 0 or img_w <= 0:
|
|
85
|
+
return
|
|
86
|
+
display_row = img_h - 1 - row
|
|
87
|
+
x = offset_x + (col + 0.5) * target_w / img_w
|
|
88
|
+
y = offset_y + (display_row + 0.5) * target_h / img_h
|
|
89
|
+
r = 4
|
|
90
|
+
canvas = self._canvas_map[view]
|
|
91
|
+
marker_id = canvas.create_oval(x - r, y - r, x + r, y + r, outline=color, width=2)
|
|
92
|
+
self._markers[view].append(marker_id)
|
|
93
|
+
|
|
94
|
+
def add_box(self, view: str, row0: int, col0: int, row1: int, col1: int, *, color: str, width: int = 2) -> None:
|
|
95
|
+
state = self._render_state.get(view)
|
|
96
|
+
if state is None:
|
|
97
|
+
return
|
|
98
|
+
img_h, img_w, offset_x, offset_y, target_w, target_h = state
|
|
99
|
+
if img_h <= 0 or img_w <= 0:
|
|
100
|
+
return
|
|
101
|
+
r0 = max(0, min(int(row0), img_h - 1))
|
|
102
|
+
r1 = max(0, min(int(row1), img_h - 1))
|
|
103
|
+
c0 = max(0, min(int(col0), img_w - 1))
|
|
104
|
+
c1 = max(0, min(int(col1), img_w - 1))
|
|
105
|
+
row_min, row_max = sorted((r0, r1))
|
|
106
|
+
col_min, col_max = sorted((c0, c1))
|
|
107
|
+
disp_row_min = img_h - 1 - row_max
|
|
108
|
+
disp_row_max = img_h - 1 - row_min
|
|
109
|
+
x0 = offset_x + col_min * target_w / img_w
|
|
110
|
+
x1 = offset_x + (col_max + 1) * target_w / img_w
|
|
111
|
+
y0 = offset_y + disp_row_min * target_h / img_h
|
|
112
|
+
y1 = offset_y + (disp_row_max + 1) * target_h / img_h
|
|
113
|
+
canvas = self._canvas_map[view]
|
|
114
|
+
box_id = canvas.create_rectangle(x0, y0, x1, y1, outline=color, width=width)
|
|
115
|
+
self._boxes[view].append(box_id)
|
|
116
|
+
|
|
117
|
+
def render_views(
|
|
118
|
+
self,
|
|
119
|
+
views: Dict[str, Tuple[np.ndarray, Tuple[float, float]]],
|
|
120
|
+
titles: Dict[str, str],
|
|
121
|
+
*,
|
|
122
|
+
crosshair: Optional[Dict[str, Tuple[int, int]]] = None,
|
|
123
|
+
show_crosshair: bool = False,
|
|
124
|
+
) -> None:
|
|
125
|
+
self._last_views = views
|
|
126
|
+
self._last_titles = titles
|
|
127
|
+
self._last_crosshair = crosshair
|
|
128
|
+
self._last_show_crosshair = bool(show_crosshair)
|
|
129
|
+
self._last_message = None
|
|
130
|
+
self._last_is_error = False
|
|
131
|
+
self._render_all_views()
|
|
132
|
+
|
|
133
|
+
def show_message(self, message: str, *, is_error: bool = False) -> None:
|
|
134
|
+
self._last_views = None
|
|
135
|
+
self._last_titles = None
|
|
136
|
+
self._last_message = message
|
|
137
|
+
self._last_is_error = is_error
|
|
138
|
+
for canvas in self._canvas_map.values():
|
|
139
|
+
self._render_message(canvas, message, is_error=is_error)
|
|
140
|
+
|
|
141
|
+
def show_message_on(self, view: str, message: str, *, is_error: bool = False) -> None:
|
|
142
|
+
self._last_views = None
|
|
143
|
+
self._last_titles = None
|
|
144
|
+
self._last_message = message
|
|
145
|
+
self._last_is_error = is_error
|
|
146
|
+
for key, canvas in self._canvas_map.items():
|
|
147
|
+
if key == view:
|
|
148
|
+
self._render_message(canvas, message, is_error=is_error)
|
|
149
|
+
else:
|
|
150
|
+
canvas.delete("all")
|
|
151
|
+
|
|
152
|
+
def _render_message(self, canvas: tk.Canvas, message: str, *, is_error: bool) -> None:
|
|
153
|
+
canvas.delete("all")
|
|
154
|
+
width = max(canvas.winfo_width(), 1)
|
|
155
|
+
height = max(canvas.winfo_height(), 1)
|
|
156
|
+
if is_error:
|
|
157
|
+
canvas.create_line(10, 10, width - 10, height - 10, fill="#cc3333", width=3)
|
|
158
|
+
canvas.create_line(10, height - 10, width - 10, 10, fill="#cc3333", width=3)
|
|
159
|
+
color = "#cc3333"
|
|
160
|
+
else:
|
|
161
|
+
color = "#dddddd"
|
|
162
|
+
canvas.create_text(
|
|
163
|
+
10,
|
|
164
|
+
10,
|
|
165
|
+
anchor="nw",
|
|
166
|
+
fill=color,
|
|
167
|
+
text=message,
|
|
168
|
+
font=("TkDefaultFont", 10, "bold"),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _render_canvas(
|
|
172
|
+
self,
|
|
173
|
+
canvas: tk.Canvas,
|
|
174
|
+
key: str,
|
|
175
|
+
img: np.ndarray,
|
|
176
|
+
res: Tuple[float, float],
|
|
177
|
+
title: str,
|
|
178
|
+
*,
|
|
179
|
+
shared_scale: Optional[float] = None,
|
|
180
|
+
) -> None:
|
|
181
|
+
canvas.delete("all")
|
|
182
|
+
self._markers[key] = []
|
|
183
|
+
self._boxes[key] = []
|
|
184
|
+
img = np.asarray(img)
|
|
185
|
+
if np.iscomplexobj(img):
|
|
186
|
+
img = np.abs(img)
|
|
187
|
+
else:
|
|
188
|
+
try:
|
|
189
|
+
img = img.astype(float, copy=False)
|
|
190
|
+
except Exception:
|
|
191
|
+
img = img.astype(np.float32, copy=False)
|
|
192
|
+
vmin, vmax = np.nanpercentile(img, (1, 99))
|
|
193
|
+
if np.isclose(vmin, vmax):
|
|
194
|
+
vmax = vmin + 1.0
|
|
195
|
+
img_norm = np.clip((img - vmin) / (vmax - vmin), 0.0, 1.0)
|
|
196
|
+
img_uint8 = (img_norm * 255).astype(np.uint8)
|
|
197
|
+
img_display = np.flipud(img_uint8)
|
|
198
|
+
|
|
199
|
+
pil_img = Image.fromarray(img_display, mode="L")
|
|
200
|
+
canvas_w = max(canvas.winfo_width(), 1)
|
|
201
|
+
canvas_h = max(canvas.winfo_height(), 1)
|
|
202
|
+
width_mm = float(img.shape[1]) * res[0]
|
|
203
|
+
height_mm = float(img.shape[0]) * res[1]
|
|
204
|
+
if shared_scale is not None and width_mm > 0 and height_mm > 0:
|
|
205
|
+
target_w = max(int(round(width_mm * shared_scale)), 1)
|
|
206
|
+
target_h = max(int(round(height_mm * shared_scale)), 1)
|
|
207
|
+
target_w = min(target_w, canvas_w)
|
|
208
|
+
target_h = min(target_h, canvas_h)
|
|
209
|
+
else:
|
|
210
|
+
if width_mm > 0 and height_mm > 0:
|
|
211
|
+
aspect = width_mm / height_mm
|
|
212
|
+
else:
|
|
213
|
+
aspect = pil_img.width / max(pil_img.height, 1)
|
|
214
|
+
canvas_aspect = canvas_w / max(canvas_h, 1)
|
|
215
|
+
if canvas_aspect >= aspect:
|
|
216
|
+
target_h = canvas_h
|
|
217
|
+
target_w = max(int(target_h * aspect), 1)
|
|
218
|
+
else:
|
|
219
|
+
target_w = canvas_w
|
|
220
|
+
target_h = max(int(target_w / aspect), 1)
|
|
221
|
+
|
|
222
|
+
resampling = getattr(Image, "Resampling", Image)
|
|
223
|
+
resample = getattr(resampling, "NEAREST")
|
|
224
|
+
pil_img = pil_img.resize((target_w, target_h), resample)
|
|
225
|
+
|
|
226
|
+
self._tk_images[key] = ImageTk.PhotoImage(pil_img)
|
|
227
|
+
x = (canvas_w - target_w) // 2
|
|
228
|
+
y = (canvas_h - target_h) // 2
|
|
229
|
+
self._render_state[key] = (img.shape[0], img.shape[1], x, y, target_w, target_h)
|
|
230
|
+
self._canvas_image_id[key] = canvas.create_image(
|
|
231
|
+
x,
|
|
232
|
+
y,
|
|
233
|
+
anchor="nw",
|
|
234
|
+
image=self._tk_images[key],
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
self._canvas_text_id[key] = canvas.create_text(
|
|
238
|
+
10,
|
|
239
|
+
10,
|
|
240
|
+
anchor="nw",
|
|
241
|
+
fill="#dddddd",
|
|
242
|
+
text=title,
|
|
243
|
+
font=("TkDefaultFont", 10, "bold"),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if self._last_show_crosshair and self._last_crosshair is not None:
|
|
247
|
+
rc = self._last_crosshair.get(key)
|
|
248
|
+
if rc is not None:
|
|
249
|
+
self._draw_crosshair(key=key, row=int(rc[0]), col=int(rc[1]))
|
|
250
|
+
|
|
251
|
+
def _draw_crosshair(self, *, key: str, row: int, col: int) -> None:
|
|
252
|
+
state = self._render_state.get(key)
|
|
253
|
+
if state is None:
|
|
254
|
+
return
|
|
255
|
+
img_h, img_w, offset_x, offset_y, target_w, target_h = state
|
|
256
|
+
if img_h <= 0 or img_w <= 0 or target_w <= 0 or target_h <= 0:
|
|
257
|
+
return
|
|
258
|
+
if row < 0 or col < 0 or row >= img_h or col >= img_w:
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
display_row = img_h - 1 - row
|
|
262
|
+
x = offset_x + (col + 0.5) * target_w / img_w
|
|
263
|
+
y = offset_y + (display_row + 0.5) * target_h / img_h
|
|
264
|
+
x0, x1 = offset_x, offset_x + target_w
|
|
265
|
+
y0, y1 = offset_y, offset_y + target_h
|
|
266
|
+
|
|
267
|
+
dash = (2, 4)
|
|
268
|
+
color = "#ffffff"
|
|
269
|
+
width = 1
|
|
270
|
+
canvas = self._canvas_map[key]
|
|
271
|
+
canvas.create_line(x0, y, x1, y, fill=color, width=width, dash=dash)
|
|
272
|
+
canvas.create_line(x, y0, x, y1, fill=color, width=width, dash=dash)
|
|
273
|
+
|
|
274
|
+
def _on_resize(self, *_: object) -> None:
|
|
275
|
+
if self._last_message is not None:
|
|
276
|
+
for canvas in self._canvas_map.values():
|
|
277
|
+
self._render_message(canvas, self._last_message, is_error=self._last_is_error)
|
|
278
|
+
return
|
|
279
|
+
if self._last_views and self._last_titles:
|
|
280
|
+
self._render_all_views()
|
|
281
|
+
|
|
282
|
+
def _render_all_views(self) -> None:
|
|
283
|
+
if not self._last_views or not self._last_titles:
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
scales = []
|
|
287
|
+
for key, canvas in self._canvas_map.items():
|
|
288
|
+
img, res = self._last_views[key]
|
|
289
|
+
canvas_w = max(canvas.winfo_width(), 1)
|
|
290
|
+
canvas_h = max(canvas.winfo_height(), 1)
|
|
291
|
+
width_mm = float(img.shape[1]) * float(res[0])
|
|
292
|
+
height_mm = float(img.shape[0]) * float(res[1])
|
|
293
|
+
if width_mm <= 0 or height_mm <= 0:
|
|
294
|
+
continue
|
|
295
|
+
scales.append(min(canvas_w / width_mm, canvas_h / height_mm))
|
|
296
|
+
shared_scale = min(scales) if scales else None
|
|
297
|
+
|
|
298
|
+
for key, canvas in self._canvas_map.items():
|
|
299
|
+
img, res = self._last_views[key]
|
|
300
|
+
title = self._last_titles.get(key, "")
|
|
301
|
+
self._render_canvas(canvas, key, img, res, title, shared_scale=shared_scale)
|
|
302
|
+
|
|
303
|
+
def _on_click(self, view: str, event: tk.Event) -> None:
|
|
304
|
+
if self._click_callback is None:
|
|
305
|
+
return
|
|
306
|
+
state = self._render_state.get(view)
|
|
307
|
+
if state is None:
|
|
308
|
+
return
|
|
309
|
+
img_h, img_w, offset_x, offset_y, target_w, target_h = state
|
|
310
|
+
if img_h <= 0 or img_w <= 0:
|
|
311
|
+
return
|
|
312
|
+
x = event.x - offset_x
|
|
313
|
+
y = event.y - offset_y
|
|
314
|
+
if x < 0 or y < 0 or x > target_w or y > target_h:
|
|
315
|
+
return
|
|
316
|
+
col = int(x * img_w / max(target_w, 1))
|
|
317
|
+
display_row = int(y * img_h / max(target_h, 1))
|
|
318
|
+
row = img_h - 1 - display_row
|
|
319
|
+
self._click_callback(view, row, col)
|
|
320
|
+
|
|
321
|
+
def _on_mousewheel(self, event: tk.Event) -> Optional[str]:
|
|
322
|
+
if self._zoom_callback is None:
|
|
323
|
+
return None
|
|
324
|
+
direction = 0
|
|
325
|
+
delta = getattr(event, "delta", 0)
|
|
326
|
+
if isinstance(delta, int) and delta != 0:
|
|
327
|
+
direction = 1 if delta > 0 else -1
|
|
328
|
+
else:
|
|
329
|
+
num = getattr(event, "num", None)
|
|
330
|
+
if num == 4:
|
|
331
|
+
direction = 1
|
|
332
|
+
elif num == 5:
|
|
333
|
+
direction = -1
|
|
334
|
+
if direction != 0:
|
|
335
|
+
try:
|
|
336
|
+
self._zoom_callback(direction)
|
|
337
|
+
except Exception:
|
|
338
|
+
pass
|
|
339
|
+
return "break"
|
|
340
|
+
return None
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from brkraw.core import config as config_core
|
|
8
|
+
from brkraw.core.config import resolve_root
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("brkraw.viewer")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _default_registry_columns() -> List[Dict[str, Any]]:
|
|
14
|
+
return [
|
|
15
|
+
{"key": "basename", "title": "Name", "width": 180},
|
|
16
|
+
{"key": "Study.ID", "title": "Study ID", "width": 120},
|
|
17
|
+
{"key": "Study.Date", "title": "Study Date", "width": 120},
|
|
18
|
+
{"key": "Study.Number", "title": "Study Number", "width": 120},
|
|
19
|
+
{"key": "Study.Operator", "title": "Study Operator", "width": 140},
|
|
20
|
+
{"key": "num_scans", "title": "Scans", "width": 70},
|
|
21
|
+
{"key": "path", "title": "Path", "width": 360},
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_viewer_config() -> Dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"cache": {
|
|
28
|
+
"enabled": False,
|
|
29
|
+
"max_items": 10,
|
|
30
|
+
},
|
|
31
|
+
"registry": {
|
|
32
|
+
"path": "viewer/registry.jsonl",
|
|
33
|
+
"columns": _default_registry_columns(),
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _deep_merge(base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]:
|
|
39
|
+
merged = dict(base)
|
|
40
|
+
for key, value in updates.items():
|
|
41
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
42
|
+
merged[key] = _deep_merge(merged[key], value)
|
|
43
|
+
else:
|
|
44
|
+
merged[key] = value
|
|
45
|
+
return merged
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_config(root: Optional[Path]) -> Dict[str, Any]:
|
|
49
|
+
data = config_core.load_config(root=root)
|
|
50
|
+
if data is None:
|
|
51
|
+
data = config_core.default_config()
|
|
52
|
+
if not isinstance(data, dict):
|
|
53
|
+
raise ValueError("config.yaml must contain a YAML mapping at the top level.")
|
|
54
|
+
return data
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_viewer_config(root: Optional[Path] = None) -> Dict[str, Any]:
|
|
58
|
+
config = _load_config(root)
|
|
59
|
+
viewer = config.get("viewer", {})
|
|
60
|
+
if not isinstance(viewer, dict):
|
|
61
|
+
viewer = {}
|
|
62
|
+
return _deep_merge(default_viewer_config(), viewer)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def ensure_viewer_config(root: Optional[Path] = None) -> Dict[str, Any]:
|
|
66
|
+
config = _load_config(root)
|
|
67
|
+
viewer = config.get("viewer", {})
|
|
68
|
+
if not isinstance(viewer, dict):
|
|
69
|
+
viewer = {}
|
|
70
|
+
merged = _deep_merge(default_viewer_config(), viewer)
|
|
71
|
+
config["viewer"] = merged
|
|
72
|
+
config_core.write_config(config, root=root)
|
|
73
|
+
return merged
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save_viewer_config(viewer: Dict[str, Any], root: Optional[Path] = None) -> None:
|
|
77
|
+
config = _load_config(root)
|
|
78
|
+
config["viewer"] = viewer
|
|
79
|
+
config_core.write_config(config, root=root)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def registry_path(root: Optional[Path] = None) -> Path:
|
|
83
|
+
viewer = load_viewer_config(root)
|
|
84
|
+
registry = viewer.get("registry", {})
|
|
85
|
+
rel = registry.get("path", "viewer/registry.jsonl")
|
|
86
|
+
if isinstance(rel, str):
|
|
87
|
+
rel_path = Path(rel)
|
|
88
|
+
else:
|
|
89
|
+
rel_path = Path("viewer/registry.jsonl")
|
|
90
|
+
if rel_path.is_absolute():
|
|
91
|
+
return rel_path
|
|
92
|
+
return resolve_root(root) / rel_path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def registry_columns(root: Optional[Path] = None) -> List[Dict[str, Any]]:
|
|
96
|
+
viewer = load_viewer_config(root)
|
|
97
|
+
registry = viewer.get("registry", {})
|
|
98
|
+
columns = registry.get("columns", [])
|
|
99
|
+
if isinstance(columns, list):
|
|
100
|
+
return [col for col in columns if isinstance(col, dict)]
|
|
101
|
+
return _default_registry_columns()
|
brkraw_viewer/plugin.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
from brkraw.core import config as config_core
|
|
9
|
+
from brkraw.core import formatter
|
|
10
|
+
|
|
11
|
+
from .apps.viewer import launch
|
|
12
|
+
from .registry import registry_status, register_paths, unregister_paths, resolve_entry_value
|
|
13
|
+
from .frames.viewer_config import ensure_viewer_config, registry_columns, registry_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[name-defined]
|
|
17
|
+
parser = subparsers.add_parser(
|
|
18
|
+
"viewer",
|
|
19
|
+
help="Launch the BrkRaw scan viewer GUI.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"path",
|
|
23
|
+
nargs="*",
|
|
24
|
+
help="Path to the Bruker study root directory.",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("--scan", type=int, default=None, help="Initial scan id.")
|
|
27
|
+
parser.add_argument("--reco", type=int, default=None, help="Initial reco id.")
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--info-spec",
|
|
30
|
+
default=None,
|
|
31
|
+
help="Optional scan info spec YAML path (use instead of the default mapping).",
|
|
32
|
+
)
|
|
33
|
+
parser.set_defaults(func=_run_viewer)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _run_viewer(args: argparse.Namespace) -> int:
|
|
37
|
+
commands = {"init", "register", "unregister", "list"}
|
|
38
|
+
paths: List[str] = list(args.path or [])
|
|
39
|
+
if paths and paths[0] in commands:
|
|
40
|
+
command = paths.pop(0)
|
|
41
|
+
if command == "init":
|
|
42
|
+
return _cmd_init()
|
|
43
|
+
if command == "register":
|
|
44
|
+
return _cmd_register(paths)
|
|
45
|
+
if command == "unregister":
|
|
46
|
+
return _cmd_unregister(paths)
|
|
47
|
+
if command == "list":
|
|
48
|
+
return _cmd_list()
|
|
49
|
+
return 2
|
|
50
|
+
if len(paths) > 1:
|
|
51
|
+
print("Error: too many paths provided for viewer launch.", flush=True)
|
|
52
|
+
return 2
|
|
53
|
+
path = paths[0] if paths else None
|
|
54
|
+
return launch(
|
|
55
|
+
path=path,
|
|
56
|
+
scan_id=args.scan,
|
|
57
|
+
reco_id=args.reco,
|
|
58
|
+
info_spec=args.info_spec,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _cmd_init() -> int:
|
|
63
|
+
ensure_viewer_config()
|
|
64
|
+
reg_path = registry_path()
|
|
65
|
+
reg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
if not reg_path.exists():
|
|
67
|
+
reg_path.write_text("", encoding="utf-8")
|
|
68
|
+
print(f"Viewer registry initialized: {reg_path}")
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cmd_register(paths: List[str]) -> int:
|
|
73
|
+
if not paths:
|
|
74
|
+
print("Error: missing path to register.")
|
|
75
|
+
return 2
|
|
76
|
+
targets = [Path(p) for p in paths]
|
|
77
|
+
try:
|
|
78
|
+
entries = register_paths(targets)
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
print(f"Error: failed to register dataset(s): {exc}")
|
|
81
|
+
return 2
|
|
82
|
+
print(f"Registered {len(entries)} dataset(s).")
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _cmd_unregister(paths: List[str]) -> int:
|
|
87
|
+
if not paths:
|
|
88
|
+
print("Error: missing path to unregister.")
|
|
89
|
+
return 2
|
|
90
|
+
targets = [Path(p) for p in paths]
|
|
91
|
+
removed = unregister_paths(targets)
|
|
92
|
+
print(f"Removed {removed} dataset(s).")
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _cmd_list() -> int:
|
|
97
|
+
logger = logging.getLogger("brkraw.viewer")
|
|
98
|
+
rows = registry_status()
|
|
99
|
+
width = config_core.output_width(root=None)
|
|
100
|
+
columns = [dict(col) for col in registry_columns()]
|
|
101
|
+
if not any(col.get("key") == "missing" for col in columns):
|
|
102
|
+
columns.append({"key": "missing", "title": "Missing", "width": 80})
|
|
103
|
+
visible = [col for col in columns if not col.get("hidden")]
|
|
104
|
+
keys = [col["key"] for col in visible]
|
|
105
|
+
formatted_rows = []
|
|
106
|
+
for entry in rows:
|
|
107
|
+
row: dict[str, object] = {}
|
|
108
|
+
for col in visible:
|
|
109
|
+
key = col["key"]
|
|
110
|
+
value = resolve_entry_value(entry, key)
|
|
111
|
+
if key == "missing" and entry.get("missing"):
|
|
112
|
+
row[key] = {"value": value, "color": "red"}
|
|
113
|
+
else:
|
|
114
|
+
row[key] = value
|
|
115
|
+
formatted_rows.append(row)
|
|
116
|
+
table = formatter.format_table(
|
|
117
|
+
"Viewer Registry",
|
|
118
|
+
tuple(keys),
|
|
119
|
+
formatted_rows,
|
|
120
|
+
width=width,
|
|
121
|
+
title_color="cyan",
|
|
122
|
+
col_widths=formatter.compute_column_widths(tuple(keys), formatted_rows),
|
|
123
|
+
)
|
|
124
|
+
logger.info("%s", table)
|
|
125
|
+
return 0
|