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.
@@ -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))