tk-rt-viewer 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tk_rt_viewer/__init__.py +101 -0
- tk_rt_viewer/event_controllers/bbox_handler.py +270 -0
- tk_rt_viewer/event_controllers/brush_handler.py +526 -0
- tk_rt_viewer/event_controllers/crosshair_handler.py +124 -0
- tk_rt_viewer/event_controllers/viewer_events.py +313 -0
- tk_rt_viewer/events.py +67 -0
- tk_rt_viewer/geometry.py +174 -0
- tk_rt_viewer/io.py +535 -0
- tk_rt_viewer/isodose_levels.py +113 -0
- tk_rt_viewer/rendering/contour_overlay.py +191 -0
- tk_rt_viewer/rendering/drawing_manager.py +75 -0
- tk_rt_viewer/rendering/dvh.py +138 -0
- tk_rt_viewer/rendering/isodose.py +399 -0
- tk_rt_viewer/rendering/layout.py +92 -0
- tk_rt_viewer/rendering/render.py +97 -0
- tk_rt_viewer/roi_operations.py +379 -0
- tk_rt_viewer/rtstruct_io.py +382 -0
- tk_rt_viewer/state/phase_manager.py +199 -0
- tk_rt_viewer/state/structure_set.py +170 -0
- tk_rt_viewer/state/viewer_cache.py +556 -0
- tk_rt_viewer/state/viewer_state.py +1406 -0
- tk_rt_viewer/viewer.py +1259 -0
- tk_rt_viewer-1.1.0.dist-info/METADATA +547 -0
- tk_rt_viewer-1.1.0.dist-info/RECORD +26 -0
- tk_rt_viewer-1.1.0.dist-info/WHEEL +4 -0
- tk_rt_viewer-1.1.0.dist-info/licenses/LICENSE +21 -0
tk_rt_viewer/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""tk_rt_viewer — SimpleITK-based DICOM MPR viewer widget for Tkinter.
|
|
2
|
+
|
|
3
|
+
Public API (re-exported here)
|
|
4
|
+
-----------------------------
|
|
5
|
+
DicomViewer
|
|
6
|
+
A ``ttk.Frame`` subclass embedding an MPR viewer. Layout is selectable
|
|
7
|
+
via ``SliceViewerState.layout_mode``: ``"mpr_wide"`` (default; axial
|
|
8
|
+
large left, coronal/sagittal stacked right), ``"mpr"`` (2x2 grid with a
|
|
9
|
+
DVH panel), or ``"single"`` (axial only). Supports secondary image
|
|
10
|
+
blending and 4DCT phase overlay via a built-in blend slider.
|
|
11
|
+
|
|
12
|
+
SliceViewerState
|
|
13
|
+
Observable state container. Holds all mutable state: images, indices,
|
|
14
|
+
window/level, ROI masks, brush settings, bounding boxes, crosshair
|
|
15
|
+
positions, and 4DCT phase data. Change events are declared as constants
|
|
16
|
+
in :mod:`tk_rt_viewer.events`.
|
|
17
|
+
|
|
18
|
+
StructureSet / RoiEntry
|
|
19
|
+
ROI mask container keyed by integer ROI number, and the typed entry it
|
|
20
|
+
stores. Used internally by ``SliceViewerState``; exposed here for
|
|
21
|
+
callers that build structure sets directly.
|
|
22
|
+
|
|
23
|
+
IsoDoseLevel / DEFAULT_ISODOSE_LEVELS / to_gy_pairs
|
|
24
|
+
Iso-dose levels expressed as a percentage of a reference dose, the
|
|
25
|
+
default ladder the overlay falls back to, and the conversion to the
|
|
26
|
+
``(Gy, colour)`` pairs ``DicomViewer.set_isodose_lines`` takes. Use
|
|
27
|
+
these to build an iso-dose settings UI without restating the levels.
|
|
28
|
+
|
|
29
|
+
Submodule API (import from the submodule)
|
|
30
|
+
-----------------------------------------
|
|
31
|
+
``tk_rt_viewer.io``
|
|
32
|
+
validate_dicom_files, find_reg_matrices,
|
|
33
|
+
load_all_series, load_dcm_series, normalize_phase_label
|
|
34
|
+
|
|
35
|
+
``tk_rt_viewer.rtstruct_io``
|
|
36
|
+
load_rt_struct, mask2rtstruct, save_structure_set,
|
|
37
|
+
resample_mask_to_original_space, random_hex_color, RtStructLoadError
|
|
38
|
+
|
|
39
|
+
``tk_rt_viewer.roi_operations``
|
|
40
|
+
interpolate_contour, apply_margin, smooth_contour,
|
|
41
|
+
boolean_operation, thin_slices
|
|
42
|
+
|
|
43
|
+
``tk_rt_viewer.events``
|
|
44
|
+
Event-name constants for ``SliceViewerState.add_listener``.
|
|
45
|
+
|
|
46
|
+
Quick start::
|
|
47
|
+
|
|
48
|
+
import tkinter as tk
|
|
49
|
+
from tk_rt_viewer import DicomViewer, SliceViewerState
|
|
50
|
+
|
|
51
|
+
root = tk.Tk()
|
|
52
|
+
state = SliceViewerState()
|
|
53
|
+
viewer = DicomViewer(root, state=state)
|
|
54
|
+
viewer.pack(fill="both", expand=True)
|
|
55
|
+
viewer.load_ct("/path/to/dicom")
|
|
56
|
+
root.mainloop()
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from typing import TYPE_CHECKING, Any
|
|
60
|
+
|
|
61
|
+
from .isodose_levels import DEFAULT_ISODOSE_LEVELS, IsoDoseLevel, to_gy_pairs
|
|
62
|
+
from .state.viewer_state import RoiEntry, SliceViewerState, StructureSet
|
|
63
|
+
|
|
64
|
+
if TYPE_CHECKING:
|
|
65
|
+
from .viewer import DicomViewer
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
"DEFAULT_ISODOSE_LEVELS",
|
|
69
|
+
"DicomViewer",
|
|
70
|
+
"IsoDoseLevel",
|
|
71
|
+
"RoiEntry",
|
|
72
|
+
"SliceViewerState",
|
|
73
|
+
"StructureSet",
|
|
74
|
+
"to_gy_pairs",
|
|
75
|
+
]
|
|
76
|
+
__version__ = "1.1.0"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def __getattr__(name: str) -> Any:
|
|
80
|
+
"""Import :class:`DicomViewer` on first access.
|
|
81
|
+
|
|
82
|
+
``viewer`` pulls in Tkinter and a Matplotlib GUI backend. Importing it
|
|
83
|
+
eagerly here meant that reaching for any part of this package — the
|
|
84
|
+
``events`` constants, the pure-SimpleITK helpers in ``io``,
|
|
85
|
+
``rtstruct_io`` and ``roi_operations`` — required a working Tkinter
|
|
86
|
+
build, so those helpers could not be used from a headless process
|
|
87
|
+
(a batch converter, an inference worker, a CI runner without
|
|
88
|
+
``python3-tk``). Deferring the import keeps the widget available as
|
|
89
|
+
``tk_rt_viewer.DicomViewer`` while leaving the GUI-free modules
|
|
90
|
+
importable on their own.
|
|
91
|
+
"""
|
|
92
|
+
if name == "DicomViewer":
|
|
93
|
+
from .viewer import DicomViewer
|
|
94
|
+
|
|
95
|
+
return DicomViewer
|
|
96
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def __dir__() -> list[str]:
|
|
100
|
+
"""Include the lazily imported names in ``dir()`` and tab completion."""
|
|
101
|
+
return sorted([*globals(), *__all__])
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""bbox_handler.py — Bounding box drag event handler.
|
|
2
|
+
|
|
3
|
+
The bounding box is stored in :class:`SliceViewerState` as physical
|
|
4
|
+
coordinates ``(x_min, y_min, width, height)``. This handler translates
|
|
5
|
+
mouse events into state updates; rendering is performed by
|
|
6
|
+
:class:`DicomViewer` through the ``"bounding_boxes_changed"`` listener.
|
|
7
|
+
|
|
8
|
+
Supported interactions:
|
|
9
|
+
- **Create**: left-click on empty space -> drag to define a new box.
|
|
10
|
+
- **Move**: left-click inside an existing box -> drag to reposition.
|
|
11
|
+
- **Resize**: left-click near an edge or corner handle -> drag to resize.
|
|
12
|
+
Handle detection tolerance is :attr:`TOLERANCE_PIXELS` pixels.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from ..state.viewer_state import SliceViewerState
|
|
21
|
+
from ..viewer import DicomViewer
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BboxEventHandler:
|
|
25
|
+
"""Handle bounding-box create / move / resize interactions."""
|
|
26
|
+
|
|
27
|
+
#: Pixel radius within which an edge or corner counts as a resize handle.
|
|
28
|
+
TOLERANCE_PIXELS: int = 5
|
|
29
|
+
|
|
30
|
+
#: Minimum allowed box dimension (in data units) during a resize.
|
|
31
|
+
_MIN_SIZE: float = 1.0
|
|
32
|
+
|
|
33
|
+
def __init__(self, state: "SliceViewerState", viewer: "DicomViewer") -> None:
|
|
34
|
+
self.state = state
|
|
35
|
+
self.viewer = viewer
|
|
36
|
+
|
|
37
|
+
self._interaction_mode: str | None = None # "create" | "move" | "resize"
|
|
38
|
+
self._resize_handle: str | None = None # "t" | "b" | "l" | "r" | corners
|
|
39
|
+
self._active_axis: str | None = None
|
|
40
|
+
self._is_dragging: bool = False
|
|
41
|
+
self._drag_start_pos_data: tuple[float, float] | None = None
|
|
42
|
+
self._original_pos: list[float] | None = None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def is_dragging(self) -> bool:
|
|
46
|
+
"""``True`` while a bounding-box interaction is in progress."""
|
|
47
|
+
return self._is_dragging
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
# Event handlers
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
def handle_press(self, event) -> bool:
|
|
53
|
+
"""Begin a create, move, or resize interaction on left-button press.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
``True`` if the handler consumed the event; ``False`` otherwise.
|
|
57
|
+
"""
|
|
58
|
+
if not self.state.bbox_visible:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
axis = self.state.current_axis
|
|
62
|
+
if not axis or event.xdata is None or event.ydata is None:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
px, py = event.xdata, event.ydata
|
|
66
|
+
bbox = self.state.bounding_boxes.get(axis)
|
|
67
|
+
handle = self._detect_handle(event, axis)
|
|
68
|
+
|
|
69
|
+
if handle and bbox is not None:
|
|
70
|
+
# Resize an existing box. _detect_handle only returns a handle
|
|
71
|
+
# when a box exists, so the bbox check is for the type checker
|
|
72
|
+
# and against future refactors breaking that invariant.
|
|
73
|
+
self._begin_drag(axis, "resize", (px, py), list(bbox))
|
|
74
|
+
self._resize_handle = handle
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
if bbox is not None and (
|
|
78
|
+
bbox[0] <= px <= bbox[0] + bbox[2] and bbox[1] <= py <= bbox[1] + bbox[3]
|
|
79
|
+
):
|
|
80
|
+
# Move the existing box.
|
|
81
|
+
self._begin_drag(axis, "move", (px, py), list(bbox))
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
# Click outside any existing box: clear and start creating a new one.
|
|
85
|
+
self.state.set_bounding_box(axis, None)
|
|
86
|
+
self._begin_drag(axis, "create", (px, py), None)
|
|
87
|
+
self.state.set_bounding_box(axis, (px, py, 0, 0))
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
def handle_motion(self, event) -> None:
|
|
91
|
+
"""Update the bounding box as the mouse moves during a drag."""
|
|
92
|
+
if (
|
|
93
|
+
not self._is_dragging
|
|
94
|
+
or not self._active_axis
|
|
95
|
+
or event.xdata is None
|
|
96
|
+
or event.ydata is None
|
|
97
|
+
):
|
|
98
|
+
return
|
|
99
|
+
self._apply_drag(event.xdata, event.ydata)
|
|
100
|
+
|
|
101
|
+
def handle_release(self, event) -> None:
|
|
102
|
+
"""End the current interaction on left-button release.
|
|
103
|
+
|
|
104
|
+
Tk can coalesce or simply drop ``motion_notify_event`` callbacks
|
|
105
|
+
during a fast drag, so the geometry last committed by
|
|
106
|
+
:meth:`handle_motion` is not guaranteed to reflect the pixel the
|
|
107
|
+
mouse was actually released at. Without a final update here, a
|
|
108
|
+
quick drag can commit a box shaped like an early, still-thin
|
|
109
|
+
intermediate frame — visually indistinguishable from a single
|
|
110
|
+
line — instead of the box the user actually drew. Applying one
|
|
111
|
+
last update using the release event's own coordinates closes
|
|
112
|
+
that gap.
|
|
113
|
+
"""
|
|
114
|
+
if event.button != 1:
|
|
115
|
+
return
|
|
116
|
+
if self._is_dragging and event.xdata is not None and event.ydata is not None:
|
|
117
|
+
self._apply_drag(event.xdata, event.ydata)
|
|
118
|
+
self._is_dragging = False
|
|
119
|
+
self._interaction_mode = None
|
|
120
|
+
self._resize_handle = None
|
|
121
|
+
self._active_axis = None
|
|
122
|
+
self._drag_start_pos_data = None
|
|
123
|
+
self._original_pos = None
|
|
124
|
+
|
|
125
|
+
def _apply_drag(self, px: float, py: float) -> None:
|
|
126
|
+
"""Update the box currently being created/moved/resized to (px, py).
|
|
127
|
+
|
|
128
|
+
Callers guarantee a drag is in progress; the guard below encodes
|
|
129
|
+
that invariant explicitly (and narrows the Optionals for type
|
|
130
|
+
checking) rather than assuming it.
|
|
131
|
+
"""
|
|
132
|
+
axis = self._active_axis
|
|
133
|
+
if axis is None or self._drag_start_pos_data is None:
|
|
134
|
+
return
|
|
135
|
+
x0, y0 = self._drag_start_pos_data
|
|
136
|
+
mode = self._interaction_mode
|
|
137
|
+
|
|
138
|
+
if mode == "create":
|
|
139
|
+
x_start, x_end = (x0, px) if x0 <= px else (px, x0)
|
|
140
|
+
y_start, y_end = (y0, py) if y0 <= py else (py, y0)
|
|
141
|
+
self.state.set_bounding_box(
|
|
142
|
+
axis, (x_start, y_start, x_end - x_start, y_end - y_start)
|
|
143
|
+
)
|
|
144
|
+
elif mode == "move":
|
|
145
|
+
if self._original_pos is None:
|
|
146
|
+
return
|
|
147
|
+
x, y, w, h = self._original_pos
|
|
148
|
+
self.state.set_bounding_box(axis, (x + px - x0, y + py - y0, w, h))
|
|
149
|
+
elif mode == "resize":
|
|
150
|
+
self._resize_bbox(px - x0, py - y0)
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# Internal helpers
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
def _begin_drag(
|
|
156
|
+
self,
|
|
157
|
+
axis: str,
|
|
158
|
+
mode: str,
|
|
159
|
+
start_pos: tuple[float, float],
|
|
160
|
+
original_pos: list[float] | None,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Initialise drag state for any of the three interaction modes."""
|
|
163
|
+
self._interaction_mode = mode
|
|
164
|
+
self._active_axis = axis
|
|
165
|
+
self._is_dragging = True
|
|
166
|
+
self._drag_start_pos_data = start_pos
|
|
167
|
+
self._original_pos = original_pos
|
|
168
|
+
|
|
169
|
+
def _detect_handle(self, event, axis: str) -> str | None:
|
|
170
|
+
"""Return the name of the resize handle under the cursor, or ``None``.
|
|
171
|
+
|
|
172
|
+
Handle names use compass notation: ``"t"``, ``"b"``, ``"l"``, ``"r"``
|
|
173
|
+
for edges and ``"tl"``, ``"tr"``, ``"bl"``, ``"br"`` for corners.
|
|
174
|
+
|
|
175
|
+
Edge-to-handle mapping (defined in data coordinates):
|
|
176
|
+
"l" = left edge (x_min)
|
|
177
|
+
"r" = right edge (x_max = x + w)
|
|
178
|
+
"b" = bottom edge (y_min; posterior in axial, inferior in cor/sag)
|
|
179
|
+
"t" = top edge (y_max = y + h; anterior in axial, superior in cor/sag)
|
|
180
|
+
|
|
181
|
+
Detection is performed in data coordinates. The pixel tolerance is
|
|
182
|
+
converted to data units via an inverse transform so that the correct
|
|
183
|
+
handle is returned regardless of ylim orientation.
|
|
184
|
+
"""
|
|
185
|
+
bbox = self.state.bounding_boxes.get(axis)
|
|
186
|
+
if bbox is None or not self.viewer.axs.get(axis):
|
|
187
|
+
return None
|
|
188
|
+
if event.xdata is None or event.ydata is None:
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
ax = self.viewer.axs[axis]
|
|
192
|
+
x, y, w, h = bbox
|
|
193
|
+
x_min, x_max = x, x + w
|
|
194
|
+
y_min, y_max = y, y + h
|
|
195
|
+
|
|
196
|
+
# Convert the pixel tolerance to data units via the inverse display
|
|
197
|
+
# transform. Before the axes has been drawn its transform can be
|
|
198
|
+
# singular, so a non-invertible / degenerate transform falls back to
|
|
199
|
+
# a 1-data-unit tolerance rather than swallowing unrelated errors.
|
|
200
|
+
try:
|
|
201
|
+
inv = ax.transData.inverted()
|
|
202
|
+
p0 = inv.transform((0, 0))
|
|
203
|
+
p1 = inv.transform((self.TOLERANCE_PIXELS, self.TOLERANCE_PIXELS))
|
|
204
|
+
tol_x = abs(p1[0] - p0[0])
|
|
205
|
+
tol_y = abs(p1[1] - p0[1])
|
|
206
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
207
|
+
tol_x = tol_y = 1.0
|
|
208
|
+
|
|
209
|
+
ex, ey = event.xdata, event.ydata
|
|
210
|
+
on_l = abs(ex - x_min) < tol_x
|
|
211
|
+
on_r = abs(ex - x_max) < tol_x
|
|
212
|
+
on_b = abs(ey - y_min) < tol_y
|
|
213
|
+
on_t = abs(ey - y_max) < tol_y
|
|
214
|
+
|
|
215
|
+
if on_t and on_l:
|
|
216
|
+
return "tl"
|
|
217
|
+
if on_t and on_r:
|
|
218
|
+
return "tr"
|
|
219
|
+
if on_b and on_l:
|
|
220
|
+
return "bl"
|
|
221
|
+
if on_b and on_r:
|
|
222
|
+
return "br"
|
|
223
|
+
if on_t:
|
|
224
|
+
return "t"
|
|
225
|
+
if on_b:
|
|
226
|
+
return "b"
|
|
227
|
+
if on_l:
|
|
228
|
+
return "l"
|
|
229
|
+
if on_r:
|
|
230
|
+
return "r"
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def _resize_bbox(self, dx: float, dy: float) -> None:
|
|
234
|
+
"""Apply a resize delta to the original box according to the active handle.
|
|
235
|
+
|
|
236
|
+
dx/dy are data-coordinate deltas (event.xdata/ydata - drag_start).
|
|
237
|
+
Because :meth:`_detect_handle` also operates in data coordinates, the
|
|
238
|
+
dragged edge always moves in the expected direction regardless of
|
|
239
|
+
ylim orientation:
|
|
240
|
+
|
|
241
|
+
dragging "t" up (dy > 0) -> increase y_max -> h += dy
|
|
242
|
+
dragging "b" down (dy < 0) -> decrease y_min -> y += dy; h -= dy
|
|
243
|
+
"""
|
|
244
|
+
handle = self._resize_handle
|
|
245
|
+
axis = self._active_axis
|
|
246
|
+
if handle is None or axis is None or self._original_pos is None:
|
|
247
|
+
return
|
|
248
|
+
x, y, w, h = self._original_pos
|
|
249
|
+
min_size = self._MIN_SIZE
|
|
250
|
+
|
|
251
|
+
if "l" in handle:
|
|
252
|
+
new_w = w - dx
|
|
253
|
+
if new_w >= min_size:
|
|
254
|
+
x += dx
|
|
255
|
+
w = new_w
|
|
256
|
+
if "r" in handle:
|
|
257
|
+
new_w = w + dx
|
|
258
|
+
if new_w >= min_size:
|
|
259
|
+
w = new_w
|
|
260
|
+
if "t" in handle:
|
|
261
|
+
new_h = h + dy
|
|
262
|
+
if new_h >= min_size:
|
|
263
|
+
h = new_h
|
|
264
|
+
if "b" in handle:
|
|
265
|
+
new_h = h - dy
|
|
266
|
+
if new_h >= min_size:
|
|
267
|
+
y += dy
|
|
268
|
+
h = new_h
|
|
269
|
+
|
|
270
|
+
self.state.set_bounding_box(axis, (x, y, w, h))
|