tk-rt-viewer 2.3.1__tar.gz → 2.3.2__tar.gz
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-2.3.1 → tk_rt_viewer-2.3.2}/.github/workflows/ci.yml +2 -2
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/CHANGELOG.md +24 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/PKG-INFO +1 -1
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/__init__.py +1 -1
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +7 -9
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/brush_handler.py +5 -9
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/crosshair_handler.py +1 -3
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/viewer_events.py +3 -4
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/geometry.py +21 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/io.py +14 -15
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/reg_io.py +4 -1
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/params.py +5 -7
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/dvh.py +4 -7
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/isodose.py +1 -1
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/layout.py +14 -4
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/view_axes.py +4 -2
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/roi_operations.py +6 -25
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rtstruct_io.py +11 -5
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/phase_manager.py +1 -4
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/secondary_manager.py +13 -3
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/viewer_state.py +16 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/viewer.py +51 -78
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_brush_handler.py +2 -2
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_reg_io.py +23 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_rtstruct_io.py +12 -6
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_viewer_state.py +35 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/.gitattributes +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/.gitignore +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/LICENSE +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/README.md +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/pyproject.toml +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/__init__.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/bbox_handler.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/rect_drag.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/events.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/isodose_levels.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/protocols.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/py.typed +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/__init__.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/deformable.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/errors.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/rigid.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/session.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/template.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/__init__.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/blit_compositor.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/contour_overlay.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/drawing_manager.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/image_layer.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/render.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/__init__.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/dose_manager.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/roi_editor.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/roi_manager.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/structure_set.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/viewer_cache.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/window_level.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_bbox3d.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_bbox3d_handler.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_event_controllers.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_geometry.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_io.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_isodose_levels.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_performance_opt.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_registration.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_render.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_rendering.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_roi_operations.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_state_extensions.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_state_managers.py +0 -0
|
@@ -12,9 +12,9 @@ jobs:
|
|
|
12
12
|
# The suite is headless by design: no Tk display is available on CI.
|
|
13
13
|
MPLBACKEND: Agg
|
|
14
14
|
steps:
|
|
15
|
-
- uses: actions/checkout@
|
|
15
|
+
- uses: actions/checkout@v5
|
|
16
16
|
|
|
17
|
-
- uses: actions/setup-python@
|
|
17
|
+
- uses: actions/setup-python@v6
|
|
18
18
|
with:
|
|
19
19
|
python-version: "3.12"
|
|
20
20
|
cache: pip
|
|
@@ -4,6 +4,30 @@ All notable changes to this project are documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [2.3.2]
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- `reg_io.save_registration` wrote matrix values longer than the 16
|
|
12
|
+
characters the DS value representation allows (a rotation cosine prints as
|
|
13
|
+
18-19), so strict readers rejected the file. Values are now formatted to
|
|
14
|
+
fit.
|
|
15
|
+
- A `set_secondary_image_data` call that raised (no primary image) left its
|
|
16
|
+
image behind as `secondary_source_image`.
|
|
17
|
+
- `mypy src/tk_rt_viewer` failed with Matplotlib 3.11 (two errors in
|
|
18
|
+
`rendering/view_axes.py` and `rendering/isodose.py`). Annotations only.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- `SliceViewerState.get_resampled_image` returns an image already on the
|
|
23
|
+
primary grid as-is when no transform is given. Switching between cached
|
|
24
|
+
4DCT phases no longer resamples the volume again.
|
|
25
|
+
- `rtstruct_io.load_rt_struct` reads the RT-STRUCT file once instead of
|
|
26
|
+
twice, and skips a malformed `ROIContourSequence` item with a warning.
|
|
27
|
+
- Internal cleanup: `geometry.mask_bounds` replaces two copies of the mask
|
|
28
|
+
bounding-box code; duplicated series reading, scheduling and box-patch
|
|
29
|
+
code were merged.
|
|
30
|
+
|
|
7
31
|
## [2.3.1]
|
|
8
32
|
|
|
9
33
|
A newly drawn 3-D box no longer spans the whole image along the axis that
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tk-rt-viewer
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.2
|
|
4
4
|
Summary: SimpleITK-based DICOM-RT MPR viewer widget for Tkinter (CT, RT-DOSE, RT-STRUCT, DVH)
|
|
5
5
|
Project-URL: Homepage, https://github.com/oki1002/DICOM_RT_Viewer
|
|
6
6
|
Project-URL: Repository, https://github.com/oki1002/DICOM_RT_Viewer
|
{tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/bbox3d_handler.py
RENAMED
|
@@ -13,6 +13,8 @@ axis the user cannot see while drawing. Along an image narrower than
|
|
|
13
13
|
:attr:`Bbox3dEventHandler.NARROW_EXTENT_MM` it is half the image instead.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
+
import SimpleITK as sitk
|
|
17
|
+
|
|
16
18
|
from ..geometry import AXIS_TO_XYZ_DIM, Box3D, fit_box_length
|
|
17
19
|
from .rect_drag import Rect, RectDragHandler
|
|
18
20
|
|
|
@@ -39,21 +41,17 @@ class Bbox3dEventHandler(RectDragHandler):
|
|
|
39
41
|
self.state.set_bounding_box_3d(None)
|
|
40
42
|
|
|
41
43
|
def _commit(self, axis: str, rect: Rect, mode: str) -> None:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
image = self.state.primary_image
|
|
45
|
+
if mode == "create" and image is not None:
|
|
46
|
+
self.state.set_bounding_box_3d(self._new_box(image, axis, rect))
|
|
44
47
|
else:
|
|
45
48
|
self.state.set_bbox_3d_from_view(axis, rect)
|
|
46
49
|
|
|
47
|
-
def _new_box(self, axis: str, rect: Rect) -> Box3D:
|
|
50
|
+
def _new_box(self, image: sitk.Image, axis: str, rect: Rect) -> Box3D:
|
|
48
51
|
"""Return a box drawn as *rect* on *axis*, centred on the displayed slice."""
|
|
49
52
|
dim = AXIS_TO_XYZ_DIM[axis]
|
|
50
53
|
center = self.state.index_to_physical(axis, self.state.indices[axis])
|
|
51
|
-
depth = fit_box_length(
|
|
52
|
-
self.state.primary_image,
|
|
53
|
-
dim,
|
|
54
|
-
self.NEW_BOX_DEPTH_MM,
|
|
55
|
-
self.NARROW_EXTENT_MM,
|
|
56
|
-
)
|
|
54
|
+
depth = fit_box_length(image, dim, self.NEW_BOX_DEPTH_MM, self.NARROW_EXTENT_MM)
|
|
57
55
|
base = Box3D(lower=(0.0, 0.0, 0.0), upper=(0.0, 0.0, 0.0))
|
|
58
56
|
return base.with_range(
|
|
59
57
|
dim, center - depth / 2.0, center + depth / 2.0
|
{tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/brush_handler.py
RENAMED
|
@@ -88,7 +88,7 @@ class BrushEventHandler:
|
|
|
88
88
|
self.is_active = False
|
|
89
89
|
self._cursor_ready = False
|
|
90
90
|
self._abandon_stroke()
|
|
91
|
-
self.
|
|
91
|
+
self.remove_cursor()
|
|
92
92
|
self.viewer.refresh_canvas()
|
|
93
93
|
|
|
94
94
|
def _abandon_stroke(self) -> None:
|
|
@@ -153,7 +153,7 @@ class BrushEventHandler:
|
|
|
153
153
|
"""Continue the stroke or update the brush cursor position."""
|
|
154
154
|
if not self.is_active or not self._hover.current_axis:
|
|
155
155
|
if self.brush_circle:
|
|
156
|
-
self.
|
|
156
|
+
self.remove_cursor()
|
|
157
157
|
return
|
|
158
158
|
|
|
159
159
|
current = self._hover.current_axis
|
|
@@ -225,19 +225,19 @@ class BrushEventHandler:
|
|
|
225
225
|
def _update_brush_cursor(self, event) -> None:
|
|
226
226
|
"""Create or reposition the circular brush cursor at the event location."""
|
|
227
227
|
if self.viewer.toolbar_mode:
|
|
228
|
-
self.
|
|
228
|
+
self.remove_cursor()
|
|
229
229
|
return
|
|
230
230
|
|
|
231
231
|
axis = self._hover.current_axis
|
|
232
232
|
if not (axis and event.xdata is not None and event.ydata is not None):
|
|
233
|
-
self.
|
|
233
|
+
self.remove_cursor()
|
|
234
234
|
return
|
|
235
235
|
|
|
236
236
|
if (
|
|
237
237
|
not self.brush_circle
|
|
238
238
|
or self.brush_circle.axes != self.viewer.axes_map[axis]
|
|
239
239
|
):
|
|
240
|
-
self.
|
|
240
|
+
self.remove_cursor()
|
|
241
241
|
roi_number = self.state.selected_roi_number
|
|
242
242
|
color = (
|
|
243
243
|
self.state.structure_set.get_color(roi_number)
|
|
@@ -259,10 +259,6 @@ class BrushEventHandler:
|
|
|
259
259
|
self.viewer.request_redraw(axis)
|
|
260
260
|
|
|
261
261
|
def remove_cursor(self) -> None:
|
|
262
|
-
"""Remove the brush cursor circle from the canvas."""
|
|
263
|
-
self._remove_brush_cursor()
|
|
264
|
-
|
|
265
|
-
def _remove_brush_cursor(self) -> None:
|
|
266
262
|
"""Remove the brush cursor circle from the canvas."""
|
|
267
263
|
if not self.brush_circle:
|
|
268
264
|
return
|
{tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/crosshair_handler.py
RENAMED
|
@@ -131,6 +131,4 @@ class CrosshairEventHandler:
|
|
|
131
131
|
def handle_release(self, event) -> None:
|
|
132
132
|
"""End the crosshair drag on left-button release."""
|
|
133
133
|
if event.button == 1:
|
|
134
|
-
self.
|
|
135
|
-
self._active_axis = None
|
|
136
|
-
self._drag_target = None
|
|
134
|
+
self.cancel()
|
{tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/viewer_events.py
RENAMED
|
@@ -48,8 +48,8 @@ ZOOM_STEP: float = 1.25
|
|
|
48
48
|
#: immediate, long enough to coalesce a wheel flick.
|
|
49
49
|
SCROLL_DEBOUNCE_MS: int = 30
|
|
50
50
|
|
|
51
|
-
# Slice
|
|
52
|
-
|
|
51
|
+
# Slice steps per navigation key (PageUp / PageDown move by 10)
|
|
52
|
+
_KEY_STEPS: dict[str, int] = {"up": 1, "down": -1, "pageup": 10, "pagedown": -10}
|
|
53
53
|
|
|
54
54
|
# Right mouse button drives the window/level drag
|
|
55
55
|
_WL_BUTTON: int = 3
|
|
@@ -418,8 +418,7 @@ class ViewerEventHandler:
|
|
|
418
418
|
axis = self._current_axis
|
|
419
419
|
if not axis or self.state.primary_image is None:
|
|
420
420
|
return
|
|
421
|
-
|
|
422
|
-
delta = deltas.get(event.key)
|
|
421
|
+
delta = _KEY_STEPS.get(event.key)
|
|
423
422
|
if delta is None:
|
|
424
423
|
return
|
|
425
424
|
current = self.state.indices[axis]
|
|
@@ -216,6 +216,27 @@ def fit_box_length(
|
|
|
216
216
|
return extent / 2.0 if extent < min_extent else length
|
|
217
217
|
|
|
218
218
|
|
|
219
|
+
def mask_bounds(
|
|
220
|
+
mask: np.ndarray, pad_voxels: tuple[int, int, int] = (0, 0, 0)
|
|
221
|
+
) -> tuple[slice, slice, slice] | None:
|
|
222
|
+
"""Return the bounding box of a ``(z, y, x)`` mask grown by *pad_voxels*.
|
|
223
|
+
|
|
224
|
+
Clamped to the array; ``None`` when the mask is empty. Lets whole-volume
|
|
225
|
+
work (a distance transform, a DVH) run on the usually small region the
|
|
226
|
+
mask occupies.
|
|
227
|
+
"""
|
|
228
|
+
occupied = [
|
|
229
|
+
np.flatnonzero(mask.any(axis=axes)) for axes in ((1, 2), (0, 2), (0, 1))
|
|
230
|
+
]
|
|
231
|
+
if any(indices.size == 0 for indices in occupied):
|
|
232
|
+
return None
|
|
233
|
+
z, y, x = (
|
|
234
|
+
slice(max(0, int(indices[0]) - pad), min(extent, int(indices[-1]) + pad + 1))
|
|
235
|
+
for indices, pad, extent in zip(occupied, pad_voxels, mask.shape, strict=True)
|
|
236
|
+
)
|
|
237
|
+
return z, y, x
|
|
238
|
+
|
|
239
|
+
|
|
219
240
|
def view_dims(axis: str) -> tuple[int, int]:
|
|
220
241
|
"""Return the physical dimensions plotted on *axis*' x and y axes.
|
|
221
242
|
|
|
@@ -62,11 +62,11 @@ import pydicom
|
|
|
62
62
|
import SimpleITK as sitk
|
|
63
63
|
from pydicom.errors import InvalidDicomError
|
|
64
64
|
|
|
65
|
+
from .reg_io import SPATIAL_REGISTRATION_SOP_CLASS_UID
|
|
65
66
|
from .window_level import compute_auto_window_level
|
|
66
67
|
|
|
67
68
|
logger = logging.getLogger(__name__)
|
|
68
69
|
|
|
69
|
-
_SPATIAL_REGISTRATION_UID = "1.2.840.10008.5.1.4.1.1.66.1"
|
|
70
70
|
_CT_IMAGE_STORAGE_UID = "1.2.840.10008.5.1.4.1.1.2"
|
|
71
71
|
_PHASE_LABEL_PATTERN = re.compile(r"\d+%")
|
|
72
72
|
|
|
@@ -248,7 +248,10 @@ def _scan_dicom_tree(dcm_root_dir: str | pathlib.Path) -> _ScanResult:
|
|
|
248
248
|
if series_uid is not None and modality:
|
|
249
249
|
scan.modality_by_series[str(series_uid)] = modality
|
|
250
250
|
|
|
251
|
-
if
|
|
251
|
+
if (
|
|
252
|
+
modality == "REG"
|
|
253
|
+
and ds.get("SOPClassUID", "") == SPATIAL_REGISTRATION_SOP_CLASS_UID
|
|
254
|
+
):
|
|
252
255
|
_collect_reg_matrices(ds, file, scan.reg_matrices, reg_series_refs)
|
|
253
256
|
|
|
254
257
|
# A REG object may name only one instance of the moving series; resolve
|
|
@@ -661,18 +664,17 @@ def select_phase_series(
|
|
|
661
664
|
# ---------------------------------------------------------------------------
|
|
662
665
|
# Internal helpers
|
|
663
666
|
# ---------------------------------------------------------------------------
|
|
664
|
-
def
|
|
667
|
+
def _read_files(
|
|
665
668
|
reader: sitk.ImageSeriesReader,
|
|
666
|
-
|
|
669
|
+
file_names: Sequence[str],
|
|
667
670
|
series_id: str,
|
|
668
|
-
) ->
|
|
669
|
-
"""
|
|
670
|
-
file_names = reader.GetGDCMSeriesFileNames(str(dcm_dir), series_id)
|
|
671
|
+
) -> sitk.Image:
|
|
672
|
+
"""Read *file_names* as one series, keeping per-slice metadata on *reader*."""
|
|
671
673
|
reader.SetFileNames(file_names)
|
|
672
674
|
reader.MetaDataDictionaryArrayUpdateOn()
|
|
673
675
|
image = reader.Execute()
|
|
674
676
|
logger.info(f"Series '{series_id}' loaded with {len(file_names)} files.")
|
|
675
|
-
return image
|
|
677
|
+
return image
|
|
676
678
|
|
|
677
679
|
|
|
678
680
|
def _axis_aligned_grid(
|
|
@@ -887,7 +889,7 @@ def _read_reg_files(
|
|
|
887
889
|
except Exception as exc:
|
|
888
890
|
logger.warning(f"Skipping unreadable REG file '{file}': {exc}")
|
|
889
891
|
continue
|
|
890
|
-
if ds.get("SOPClassUID", "") ==
|
|
892
|
+
if ds.get("SOPClassUID", "") == SPATIAL_REGISTRATION_SOP_CLASS_UID:
|
|
891
893
|
_collect_reg_matrices(ds, pathlib.Path(file), by_sop, by_series)
|
|
892
894
|
return by_sop, by_series
|
|
893
895
|
|
|
@@ -944,7 +946,8 @@ def _load_all_series_impl(
|
|
|
944
946
|
if modality.upper() in NON_IMAGE_MODALITIES:
|
|
945
947
|
logger.info(f"Skipping {modality} series '{sid}' (not an image).")
|
|
946
948
|
continue
|
|
947
|
-
|
|
949
|
+
file_names = reader.GetGDCMSeriesFileNames(str(dcm_dir), sid)
|
|
950
|
+
raw_image = _read_files(reader, file_names, sid)
|
|
948
951
|
description, info = _build_series_info(
|
|
949
952
|
reader, raw_image, _find_reg_matrix(scan, file_names, sid), sid
|
|
950
953
|
)
|
|
@@ -1133,12 +1136,8 @@ def _load_scanned_series(
|
|
|
1133
1136
|
f"'{entry.series_dir}'."
|
|
1134
1137
|
)
|
|
1135
1138
|
|
|
1136
|
-
reader.SetFileNames(file_names)
|
|
1137
|
-
reader.MetaDataDictionaryArrayUpdateOn()
|
|
1138
|
-
raw_image = reader.Execute()
|
|
1139
1139
|
series_id = entry.series_uid or str(entry.series_dir)
|
|
1140
|
-
|
|
1141
|
-
|
|
1140
|
+
raw_image = _read_files(reader, file_names, series_id)
|
|
1142
1141
|
reg_matrix = _match_reg_matrix(
|
|
1143
1142
|
reader, len(file_names), entry.series_uid, reg_matrices
|
|
1144
1143
|
)
|
|
@@ -23,6 +23,7 @@ import numpy as np
|
|
|
23
23
|
import SimpleITK as sitk
|
|
24
24
|
from pydicom.dataset import Dataset, FileDataset, FileMetaDataset
|
|
25
25
|
from pydicom.uid import UID, ExplicitVRLittleEndian, generate_uid
|
|
26
|
+
from pydicom.valuerep import DSfloat
|
|
26
27
|
|
|
27
28
|
logger = logging.getLogger(__name__)
|
|
28
29
|
|
|
@@ -260,8 +261,10 @@ def _registration_item(
|
|
|
260
261
|
"""Build one item of the Registration Sequence."""
|
|
261
262
|
matrix_item = Dataset()
|
|
262
263
|
matrix_item.FrameOfReferenceTransformationMatrixType = "RIGID"
|
|
264
|
+
# DS values are limited to 16 characters; a plain float such as a rotation
|
|
265
|
+
# cosine prints 18-19, which strict readers reject
|
|
263
266
|
matrix_item.FrameOfReferenceTransformationMatrix = [
|
|
264
|
-
float(value) for value in matrix.reshape(16)
|
|
267
|
+
DSfloat(float(value), auto_format=True) for value in matrix.reshape(16)
|
|
265
268
|
]
|
|
266
269
|
|
|
267
270
|
matrix_registration = Dataset()
|
|
@@ -140,19 +140,17 @@ def params_from_resample_transform(
|
|
|
140
140
|
center_array = np.array(center)
|
|
141
141
|
|
|
142
142
|
# Re-express T(p) = R p + offset about the requested centre, i.e. solve
|
|
143
|
-
# R (p - c) + c + t = R p + offset for t.
|
|
144
|
-
|
|
145
|
-
recentred.SetCenter(center)
|
|
146
|
-
recentred.SetMatrix(matrix.ravel().tolist())
|
|
143
|
+
# R (p - c) + c + t = R p + offset for t. The angles depend on R alone,
|
|
144
|
+
# so they are read from the motion transform as it is
|
|
147
145
|
translation = offset + matrix @ center_array - center_array
|
|
148
146
|
|
|
149
147
|
return RigidParams(
|
|
150
148
|
vert=float(translation[1]),
|
|
151
149
|
lat=float(translation[0]),
|
|
152
150
|
long=float(translation[2]),
|
|
153
|
-
roll=math.degrees(
|
|
154
|
-
pitch=math.degrees(
|
|
155
|
-
yaw=math.degrees(
|
|
151
|
+
roll=math.degrees(motion.GetAngleZ()),
|
|
152
|
+
pitch=math.degrees(motion.GetAngleX()),
|
|
153
|
+
yaw=math.degrees(motion.GetAngleY()),
|
|
156
154
|
)
|
|
157
155
|
|
|
158
156
|
|
|
@@ -11,6 +11,8 @@ import numpy as np
|
|
|
11
11
|
import SimpleITK as sitk
|
|
12
12
|
from matplotlib.axes import Axes
|
|
13
13
|
|
|
14
|
+
from ..geometry import mask_bounds
|
|
15
|
+
|
|
14
16
|
if TYPE_CHECKING:
|
|
15
17
|
from ..state.viewer_state import SliceViewerState
|
|
16
18
|
|
|
@@ -39,14 +41,9 @@ class DvhPanel:
|
|
|
39
41
|
Cropping to the mask's bounding box first avoids scanning the whole
|
|
40
42
|
volume for a typically small ROI.
|
|
41
43
|
"""
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
]
|
|
45
|
-
if any(indices.size == 0 for indices in occupied):
|
|
44
|
+
box = mask_bounds(mask_arr)
|
|
45
|
+
if box is None:
|
|
46
46
|
return np.empty(0, dtype=dose_arr.dtype)
|
|
47
|
-
box = tuple(
|
|
48
|
-
slice(int(indices[0]), int(indices[-1]) + 1) for indices in occupied
|
|
49
|
-
)
|
|
50
47
|
mask_box = mask_arr[box]
|
|
51
48
|
return np.asarray(dose_arr[box][mask_box != 0])
|
|
52
49
|
|
|
@@ -270,7 +270,7 @@ class IsoDoseOverlay:
|
|
|
270
270
|
zorder=2,
|
|
271
271
|
)
|
|
272
272
|
# Per-instance override (documented Matplotlib pattern)
|
|
273
|
-
fill.format_cursor_data = _format_fill_cursor_data # type: ignore[method-assign,assignment]
|
|
273
|
+
fill.format_cursor_data = _format_fill_cursor_data # type: ignore[method-assign,assignment,unused-ignore]
|
|
274
274
|
self._fill[axis] = fill
|
|
275
275
|
self._on_artists_changed(axis)
|
|
276
276
|
return
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""layout.py — Builds the Matplotlib Axes for each layout mode."""
|
|
2
2
|
|
|
3
3
|
from collections.abc import Callable
|
|
4
|
+
from typing import cast
|
|
4
5
|
|
|
5
6
|
import matplotlib.gridspec as gridspec
|
|
6
7
|
from matplotlib.axes import Axes
|
|
@@ -50,6 +51,7 @@ class LayoutManager:
|
|
|
50
51
|
For ``"single"``, ``axs`` is ``{"axial": Axes}``. ``dvh_ax`` is
|
|
51
52
|
``None`` except for ``"mpr"``.
|
|
52
53
|
"""
|
|
54
|
+
axs: dict[str, Axes]
|
|
53
55
|
if mode == "single":
|
|
54
56
|
axs = {"axial": self._add_view(111)}
|
|
55
57
|
dvh_ax = None
|
|
@@ -76,12 +78,20 @@ class LayoutManager:
|
|
|
76
78
|
)
|
|
77
79
|
|
|
78
80
|
for ax in axs.values():
|
|
79
|
-
|
|
80
|
-
ax.tick_params(colors="white")
|
|
81
|
-
ax.set_axis_off()
|
|
81
|
+
self.style_view(ax)
|
|
82
82
|
|
|
83
83
|
return axs, dvh_ax
|
|
84
84
|
|
|
85
|
+
@staticmethod
|
|
86
|
+
def style_view(ax: Axes) -> None:
|
|
87
|
+
"""Apply the image-view styling (also after ``Axes.clear()``)."""
|
|
88
|
+
ax.set_facecolor("black")
|
|
89
|
+
ax.tick_params(colors="white")
|
|
90
|
+
ax.set_axis_off()
|
|
91
|
+
|
|
85
92
|
def _add_view(self, spec) -> ImageViewAxes:
|
|
86
93
|
"""Add an image view in the grid cell *spec*."""
|
|
87
|
-
|
|
94
|
+
# add_subplot is typed as returning Axes, whatever axes_class is
|
|
95
|
+
return cast(
|
|
96
|
+
ImageViewAxes, self._fig.add_subplot(spec, axes_class=ImageViewAxes)
|
|
97
|
+
)
|
|
@@ -109,7 +109,9 @@ class ImageViewAxes(Axes):
|
|
|
109
109
|
resized = (
|
|
110
110
|
previous is not None and previous[:2] == limits and previous[2] != ratio
|
|
111
111
|
)
|
|
112
|
-
old_fit =
|
|
112
|
+
old_fit = (
|
|
113
|
+
self._fit_spans(previous[2]) if resized and previous is not None else None
|
|
114
|
+
)
|
|
113
115
|
new_fit = self._fit_spans(ratio)
|
|
114
116
|
if old_fit is not None and new_fit is not None:
|
|
115
117
|
zoom = old_fit[0] / span_x
|
|
@@ -140,7 +142,7 @@ class ImageViewAxes(Axes):
|
|
|
140
142
|
width, height = self.bbox.width, self.bbox.height
|
|
141
143
|
if width <= 0 or height <= 0:
|
|
142
144
|
return None
|
|
143
|
-
return height / width
|
|
145
|
+
return float(height / width)
|
|
144
146
|
|
|
145
147
|
def _fit_spans(self, ratio: float | None) -> tuple[float, float] | None:
|
|
146
148
|
"""Return the spans showing the whole slice in a box of *ratio*."""
|
|
@@ -14,13 +14,14 @@ result keeps the input's origin / spacing / direction.
|
|
|
14
14
|
import logging
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
from enum import Enum, auto
|
|
17
|
+
from itertools import pairwise
|
|
17
18
|
|
|
18
19
|
import numpy as np
|
|
19
20
|
import SimpleITK as sitk
|
|
20
21
|
from scipy.ndimage import distance_transform_edt, gaussian_filter
|
|
21
22
|
from scipy.ndimage import shift as ndshift
|
|
22
23
|
|
|
23
|
-
from .geometry import resample_binary_mask
|
|
24
|
+
from .geometry import mask_bounds, resample_binary_mask
|
|
24
25
|
|
|
25
26
|
logger = logging.getLogger(__name__)
|
|
26
27
|
|
|
@@ -209,7 +210,7 @@ def interpolate_contour(mask_image: sitk.Image) -> sitk.Image:
|
|
|
209
210
|
|
|
210
211
|
result = binary.copy()
|
|
211
212
|
n_filled = 0
|
|
212
|
-
for prev_z, next_z in
|
|
213
|
+
for prev_z, next_z in pairwise(nonempty):
|
|
213
214
|
gap = next_z - prev_z
|
|
214
215
|
if gap <= 1:
|
|
215
216
|
continue
|
|
@@ -258,27 +259,6 @@ def _margin_sampling(
|
|
|
258
259
|
return (scaled[2], scaled[1], scaled[0])
|
|
259
260
|
|
|
260
261
|
|
|
261
|
-
def _margin_bounds(
|
|
262
|
-
mask: np.ndarray, pad_voxels: tuple[int, int, int]
|
|
263
|
-
) -> tuple[slice, slice, slice] | None:
|
|
264
|
-
"""Return the mask's bounding box grown by *pad_voxels*, or ``None`` if empty.
|
|
265
|
-
|
|
266
|
-
The distance transform only needs to run inside the region the margin
|
|
267
|
-
can reach, which is usually a small fraction of the grid.
|
|
268
|
-
"""
|
|
269
|
-
occupied = [
|
|
270
|
-
np.flatnonzero(mask.any(axis=axes)) for axes in ((1, 2), (0, 2), (0, 1))
|
|
271
|
-
]
|
|
272
|
-
if any(indices.size == 0 for indices in occupied):
|
|
273
|
-
return None
|
|
274
|
-
bounds = []
|
|
275
|
-
for indices, pad, extent in zip(occupied, pad_voxels, mask.shape, strict=True):
|
|
276
|
-
lo = max(0, int(indices[0]) - pad)
|
|
277
|
-
hi = min(extent, int(indices[-1]) + pad + 1)
|
|
278
|
-
bounds.append(slice(lo, hi))
|
|
279
|
-
return (bounds[0], bounds[1], bounds[2])
|
|
280
|
-
|
|
281
|
-
|
|
282
262
|
def apply_margin(mask_image: sitk.Image, config: MarginConfig) -> sitk.Image:
|
|
283
263
|
"""Apply a true Euclidean margin to a binary mask.
|
|
284
264
|
|
|
@@ -310,14 +290,15 @@ def apply_margin(mask_image: sitk.Image, config: MarginConfig) -> sitk.Image:
|
|
|
310
290
|
mask = sitk.GetArrayViewFromImage(mask_image).astype(bool) # (z, y, x)
|
|
311
291
|
result = np.zeros_like(mask)
|
|
312
292
|
|
|
313
|
-
#
|
|
293
|
+
# The distance transform only runs where the margin can reach; only a
|
|
294
|
+
# dilation reaches outside the mask's bounding box
|
|
314
295
|
reach_mm = [radius + abs(off) for radius, off in zip(radii, offset, strict=True)]
|
|
315
296
|
pad_voxels = (
|
|
316
297
|
int(np.ceil(reach_mm[2] / spacing[2])) + 2 if expand else 2,
|
|
317
298
|
int(np.ceil(reach_mm[1] / spacing[1])) + 2 if expand else 2,
|
|
318
299
|
int(np.ceil(reach_mm[0] / spacing[0])) + 2 if expand else 2,
|
|
319
300
|
)
|
|
320
|
-
bounds =
|
|
301
|
+
bounds = mask_bounds(mask, pad_voxels)
|
|
321
302
|
if bounds is None:
|
|
322
303
|
logger.warning("Margin skipped: the mask is empty.")
|
|
323
304
|
return mask_image
|
|
@@ -34,7 +34,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
34
34
|
from typing import TYPE_CHECKING, Any, TypedDict
|
|
35
35
|
|
|
36
36
|
import numpy as np
|
|
37
|
-
import pydicom
|
|
38
37
|
import SimpleITK as sitk
|
|
39
38
|
from rt_utils import RTStructBuilder
|
|
40
39
|
|
|
@@ -213,7 +212,9 @@ def load_rt_struct(
|
|
|
213
212
|
dicom_series_path=str(ct_dir),
|
|
214
213
|
rt_struct_path=str(rtstruct_path),
|
|
215
214
|
)
|
|
216
|
-
ds
|
|
215
|
+
# rtstruct.ds is the file already parsed by rt-utils; reading it again
|
|
216
|
+
# would double the I/O for a large structure set
|
|
217
|
+
ds = rtstruct.ds
|
|
217
218
|
# ROIContourSequence is conditional: a structure set without contours
|
|
218
219
|
# may omit it, which must yield {} rather than AttributeError
|
|
219
220
|
roi_name_map: dict[int, str] = {
|
|
@@ -228,9 +229,14 @@ def load_rt_struct(
|
|
|
228
229
|
|
|
229
230
|
roi_tasks: list[tuple[int, str, str]] = []
|
|
230
231
|
for roi_contour in roi_contours:
|
|
231
|
-
|
|
232
|
+
# One malformed item is skipped, like an ROI whose mask cannot be built
|
|
233
|
+
try:
|
|
234
|
+
roi_number = int(roi_contour.ReferencedROINumber)
|
|
235
|
+
color_hex = _extract_roi_color(roi_contour)
|
|
236
|
+
except (AttributeError, TypeError, ValueError) as exc:
|
|
237
|
+
logger.warning(f"Skipping a malformed ROIContourSequence item: {exc}")
|
|
238
|
+
continue
|
|
232
239
|
roi_name = roi_name_map.get(roi_number, f"ROI_{roi_number}")
|
|
233
|
-
color_hex = _extract_roi_color(roi_contour)
|
|
234
240
|
roi_tasks.append((roi_number, roi_name, color_hex))
|
|
235
241
|
|
|
236
242
|
if not roi_tasks:
|
|
@@ -254,7 +260,7 @@ def load_rt_struct(
|
|
|
254
260
|
f"{sorted(duplicate_names)}; resolving each by ROINumber instead "
|
|
255
261
|
"of name to avoid every same-named ROI receiving the same mask."
|
|
256
262
|
)
|
|
257
|
-
for roi in
|
|
263
|
+
for roi in ds.StructureSetROISequence:
|
|
258
264
|
if roi.ROIName in duplicate_names:
|
|
259
265
|
unique_name = f"__tk_rt_viewer_load_tmp_{roi.ROINumber}__"
|
|
260
266
|
lookup_name_by_number[int(roi.ROINumber)] = unique_name
|
|
@@ -83,10 +83,7 @@ class PhaseManager:
|
|
|
83
83
|
|
|
84
84
|
def clear(self) -> None:
|
|
85
85
|
"""Drop all phases, the resampled cache, and the current-phase marker."""
|
|
86
|
-
self.
|
|
87
|
-
self._rebuild_view()
|
|
88
|
-
self._resampled.clear()
|
|
89
|
-
self._current_phase = None
|
|
86
|
+
self.set_all({})
|
|
90
87
|
|
|
91
88
|
def _rebuild_view(self) -> None:
|
|
92
89
|
"""Refresh the read-only view after :attr:`_phases` has been replaced."""
|
|
@@ -73,12 +73,22 @@ class SecondaryManager:
|
|
|
73
73
|
|
|
74
74
|
Returns:
|
|
75
75
|
The resampled image, or ``None`` when *image* is ``None``.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
Whatever the resample callable raises (e.g. ``RuntimeError``
|
|
79
|
+
without a primary image); the stored source is then unchanged.
|
|
76
80
|
"""
|
|
81
|
+
fill_value = float(fill_value)
|
|
82
|
+
# Resampled before anything is stored, so a failure leaves no source
|
|
83
|
+
# behind that the displayed image does not come from
|
|
84
|
+
resampled = (
|
|
85
|
+
None if image is None else self._resample(image, transform, fill_value)
|
|
86
|
+
)
|
|
77
87
|
self._source = image
|
|
78
88
|
self._transform = transform
|
|
79
|
-
self._fill_value =
|
|
80
|
-
self._resampled =
|
|
81
|
-
return
|
|
89
|
+
self._fill_value = fill_value
|
|
90
|
+
self._resampled = resampled
|
|
91
|
+
return resampled
|
|
82
92
|
|
|
83
93
|
def set_transform(
|
|
84
94
|
self, transform: sitk.Transform | None, resampled: sitk.Image | None = None
|
|
@@ -97,6 +97,16 @@ WINDOW_LEVEL_TARGETS: tuple[str, ...] = ("primary", "secondary")
|
|
|
97
97
|
MIN_BRUSH_SIZE_MM: float = 0.1
|
|
98
98
|
|
|
99
99
|
|
|
100
|
+
def _same_grid(image: sitk.Image, reference: sitk.Image) -> bool:
|
|
101
|
+
"""Return whether *image* has exactly *reference*'s voxel grid."""
|
|
102
|
+
return bool(
|
|
103
|
+
image.GetSize() == reference.GetSize()
|
|
104
|
+
and image.GetOrigin() == reference.GetOrigin()
|
|
105
|
+
and image.GetSpacing() == reference.GetSpacing()
|
|
106
|
+
and image.GetDirection() == reference.GetDirection()
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
100
110
|
@dataclass(eq=False)
|
|
101
111
|
class SliceViewerState:
|
|
102
112
|
"""State container for the 3-plane DICOM viewer.
|
|
@@ -561,6 +571,10 @@ class SliceViewerState:
|
|
|
561
571
|
) -> sitk.Image:
|
|
562
572
|
"""Resample *image* onto the primary image grid (linear interpolation).
|
|
563
573
|
|
|
574
|
+
An *image* already on the primary grid is returned as-is when no
|
|
575
|
+
*transform* is given: an identity resample would only copy it. That
|
|
576
|
+
is the case for every 4DCT phase leaving the LRU cache.
|
|
577
|
+
|
|
564
578
|
Args:
|
|
565
579
|
image: The source image.
|
|
566
580
|
transform: Maps primary-grid points into *image* (a registration
|
|
@@ -576,6 +590,8 @@ class SliceViewerState:
|
|
|
576
590
|
"Cannot resample: no primary image is loaded, so there is no "
|
|
577
591
|
"reference grid to resample onto."
|
|
578
592
|
)
|
|
593
|
+
if transform is None and _same_grid(image, self.primary_image):
|
|
594
|
+
return image
|
|
579
595
|
resample = sitk.ResampleImageFilter()
|
|
580
596
|
resample.SetReferenceImage(self.primary_image)
|
|
581
597
|
resample.SetInterpolator(sitk.sitkLinear)
|
|
@@ -233,7 +233,7 @@ class DicomViewer(ttk.Frame):
|
|
|
233
233
|
self.viewer_state, on_artists_changed=self._compositor.invalidate
|
|
234
234
|
)
|
|
235
235
|
self._contour_redraws = ContourRedrawCoalescer(
|
|
236
|
-
schedule=self.
|
|
236
|
+
schedule=self.schedule,
|
|
237
237
|
cancel=self._safe_after_cancel,
|
|
238
238
|
redraw=self._update_all_contours,
|
|
239
239
|
active_rois=lambda: self.viewer_state.active_contours,
|
|
@@ -331,8 +331,11 @@ class DicomViewer(ttk.Frame):
|
|
|
331
331
|
def schedule(self, delay_ms: int, callback: Callable[[], None]) -> str | None:
|
|
332
332
|
"""Run *callback* after *delay_ms*, returning a cancellation handle.
|
|
333
333
|
|
|
334
|
-
Returns ``None`` when no Tk event loop is available
|
|
335
|
-
fall back to acting immediately instead of
|
|
334
|
+
Returns ``None`` when no Tk event loop is available (or the widget is
|
|
335
|
+
gone), so a caller can fall back to acting immediately instead of
|
|
336
|
+
losing the work. Also called from the contour-build workers: ``after``
|
|
337
|
+
is thread-safe with a threaded Tcl (CPython's default; see the
|
|
338
|
+
README's "Threading model").
|
|
336
339
|
"""
|
|
337
340
|
try:
|
|
338
341
|
return self.after(delay_ms, callback)
|
|
@@ -526,9 +529,7 @@ class DicomViewer(ttk.Frame):
|
|
|
526
529
|
"""
|
|
527
530
|
for ax in self.axs.values():
|
|
528
531
|
ax.clear()
|
|
529
|
-
|
|
530
|
-
ax.tick_params(colors="white")
|
|
531
|
-
ax.set_axis_off()
|
|
532
|
+
LayoutManager.style_view(ax)
|
|
532
533
|
self.image_layer.reset()
|
|
533
534
|
self.isodose.reset()
|
|
534
535
|
self.contours.reset()
|
|
@@ -628,35 +629,9 @@ class DicomViewer(ttk.Frame):
|
|
|
628
629
|
return
|
|
629
630
|
patch = self.bbox_patches[axis]
|
|
630
631
|
if patch is None:
|
|
631
|
-
patch =
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
0,
|
|
635
|
-
linewidth=1.0,
|
|
636
|
-
edgecolor="red",
|
|
637
|
-
facecolor="none",
|
|
638
|
-
visible=False,
|
|
639
|
-
)
|
|
640
|
-
ax.add_patch(patch)
|
|
641
|
-
self.bbox_patches[axis] = patch
|
|
642
|
-
self._compositor.invalidate(axis)
|
|
643
|
-
|
|
644
|
-
if bbox is None or not self.viewer_state.bbox_visible:
|
|
645
|
-
if patch.get_visible():
|
|
646
|
-
patch.set_visible(False)
|
|
647
|
-
self._compositor.invalidate(axis)
|
|
648
|
-
patch.set_xy((0, 0))
|
|
649
|
-
patch.set_width(0)
|
|
650
|
-
patch.set_height(0)
|
|
651
|
-
else:
|
|
652
|
-
x, y, w, h = bbox
|
|
653
|
-
patch.set_xy((x, y))
|
|
654
|
-
patch.set_width(w)
|
|
655
|
-
patch.set_height(h)
|
|
656
|
-
if not patch.get_visible():
|
|
657
|
-
patch.set_visible(True)
|
|
658
|
-
self._compositor.invalidate(axis)
|
|
659
|
-
self.drawing_manager.add_request(axis)
|
|
632
|
+
patch = self.bbox_patches[axis] = self._add_box_patch(axis, ax)
|
|
633
|
+
rect = bbox if self.viewer_state.bbox_visible else None
|
|
634
|
+
self._place_box_patch(axis, patch, rect)
|
|
660
635
|
|
|
661
636
|
def _on_bounding_box_3d_changed(self, box: Box3D | None) -> None:
|
|
662
637
|
"""Redraw every view's projection of the volumetric bounding box."""
|
|
@@ -681,35 +656,48 @@ class DicomViewer(ttk.Frame):
|
|
|
681
656
|
|
|
682
657
|
patch = self.bbox_3d_patches[axis]
|
|
683
658
|
if patch is None:
|
|
684
|
-
patch =
|
|
685
|
-
(0, 0),
|
|
686
|
-
0,
|
|
687
|
-
0,
|
|
688
|
-
linewidth=1.0,
|
|
689
|
-
edgecolor="red",
|
|
690
|
-
facecolor="none",
|
|
691
|
-
visible=False,
|
|
692
|
-
)
|
|
693
|
-
ax.add_patch(patch)
|
|
694
|
-
self.bbox_3d_patches[axis] = patch
|
|
695
|
-
self._compositor.invalidate(axis)
|
|
696
|
-
|
|
659
|
+
patch = self.bbox_3d_patches[axis] = self._add_box_patch(axis, ax)
|
|
697
660
|
if box is None or not self.viewer_state.bbox_3d_visible:
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
661
|
+
self._place_box_patch(axis, patch, None)
|
|
662
|
+
return
|
|
663
|
+
slice_coord = self.viewer_state.index_to_physical(
|
|
664
|
+
axis, self.viewer_state.indices[axis]
|
|
665
|
+
)
|
|
666
|
+
patch.set_linestyle(
|
|
667
|
+
"solid" if box.contains_coordinate(axis, slice_coord) else "dashed"
|
|
668
|
+
)
|
|
669
|
+
self._place_box_patch(axis, patch, box.project(axis))
|
|
670
|
+
|
|
671
|
+
def _add_box_patch(self, axis: str, ax: Axes) -> Rectangle:
|
|
672
|
+
"""Add a hidden box outline to *ax* and invalidate its artist list."""
|
|
673
|
+
patch = Rectangle(
|
|
674
|
+
(0, 0),
|
|
675
|
+
0,
|
|
676
|
+
0,
|
|
677
|
+
linewidth=1.0,
|
|
678
|
+
edgecolor="red",
|
|
679
|
+
facecolor="none",
|
|
680
|
+
visible=False,
|
|
681
|
+
)
|
|
682
|
+
ax.add_patch(patch)
|
|
683
|
+
self._compositor.invalidate(axis)
|
|
684
|
+
return patch
|
|
685
|
+
|
|
686
|
+
def _place_box_patch(
|
|
687
|
+
self,
|
|
688
|
+
axis: str,
|
|
689
|
+
patch: Rectangle,
|
|
690
|
+
rect: tuple[float, float, float, float] | None,
|
|
691
|
+
) -> None:
|
|
692
|
+
"""Move *patch* onto *rect* ``(x, y, width, height)``, or hide it for ``None``.
|
|
693
|
+
|
|
694
|
+
The artist list is invalidated only when the visibility changes.
|
|
695
|
+
"""
|
|
696
|
+
visible = rect is not None
|
|
697
|
+
patch.set_bounds(*(rect if rect is not None else (0, 0, 0, 0)))
|
|
698
|
+
if patch.get_visible() != visible:
|
|
699
|
+
patch.set_visible(visible)
|
|
700
|
+
self._compositor.invalidate(axis)
|
|
713
701
|
self.drawing_manager.add_request(axis)
|
|
714
702
|
|
|
715
703
|
def _on_all_contours_changed(self, structure_set) -> None:
|
|
@@ -730,21 +718,6 @@ class DicomViewer(ttk.Frame):
|
|
|
730
718
|
"""
|
|
731
719
|
self._contour_redraws.notify_built(roi_number)
|
|
732
720
|
|
|
733
|
-
def _schedule_from_worker(
|
|
734
|
-
self, delay_ms: int, callback: Callable[[], None]
|
|
735
|
-
) -> str | None:
|
|
736
|
-
"""Schedule *callback* on the Tk main loop from a worker thread.
|
|
737
|
-
|
|
738
|
-
``after`` is thread-safe with a threaded Tcl (CPython's default; see
|
|
739
|
-
the README's "Threading model").
|
|
740
|
-
"""
|
|
741
|
-
try:
|
|
742
|
-
return self.after(delay_ms, callback)
|
|
743
|
-
except (RuntimeError, tk.TclError):
|
|
744
|
-
# The main loop has exited or the widget is gone: nothing to redraw
|
|
745
|
-
logger.debug("Contour cache built after teardown; redraw skipped.")
|
|
746
|
-
return None
|
|
747
|
-
|
|
748
721
|
def _on_rt_dose_changed(self, image) -> None:
|
|
749
722
|
"""Update the dose overlay and DVH panel when the RT-DOSE changes."""
|
|
750
723
|
self._update_blend_slider_visibility()
|
|
@@ -239,7 +239,7 @@ class TestResetAfterAxesCleared:
|
|
|
239
239
|
ax.clear() # Mirrors DicomViewer._reset_artists()
|
|
240
240
|
|
|
241
241
|
try:
|
|
242
|
-
handler.
|
|
242
|
+
handler.remove_cursor()
|
|
243
243
|
except NotImplementedError:
|
|
244
244
|
pass
|
|
245
245
|
else:
|
|
@@ -270,7 +270,7 @@ class TestResetAfterAxesCleared:
|
|
|
270
270
|
assert handler.brush_circle is None
|
|
271
271
|
|
|
272
272
|
# Must not raise: no stale artist reference remains.
|
|
273
|
-
handler.
|
|
273
|
+
handler.remove_cursor()
|
|
274
274
|
handler.handle_motion(_Event(xdata=1.0, ydata=1.0))
|
|
275
275
|
handler.remove_cursor()
|
|
276
276
|
finally:
|
|
@@ -116,6 +116,29 @@ class TestSaveRegistration:
|
|
|
116
116
|
moving.SeriesInstanceUID,
|
|
117
117
|
]
|
|
118
118
|
|
|
119
|
+
def test_matrix_values_fit_the_ds_length_limit(self, tmp_path) -> None:
|
|
120
|
+
"""A rotation's cosines print as 18-19 characters; DS allows 16."""
|
|
121
|
+
matrix = transform_to_matrix(
|
|
122
|
+
motion_transform(
|
|
123
|
+
RigidParams(lat=1.23456789, roll=7.3, pitch=-2.9, yaw=11.1),
|
|
124
|
+
center=(0.0, 0.0, 0.0),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
path = tmp_path / "reg.dcm"
|
|
128
|
+
save_registration(path, matrix, make_reference(), make_reference("1.2.9"))
|
|
129
|
+
|
|
130
|
+
ds = pydicom.dcmread(path)
|
|
131
|
+
values = (
|
|
132
|
+
ds.RegistrationSequence[1]
|
|
133
|
+
.MatrixRegistrationSequence[0]
|
|
134
|
+
.MatrixSequence[0]
|
|
135
|
+
.FrameOfReferenceTransformationMatrix
|
|
136
|
+
)
|
|
137
|
+
assert all(len(str(value)) <= 16 for value in values)
|
|
138
|
+
assert np.array(values, dtype=float).reshape(4, 4) == pytest.approx(
|
|
139
|
+
matrix, abs=1e-12
|
|
140
|
+
)
|
|
141
|
+
|
|
119
142
|
def test_rejects_a_reflection(self, tmp_path) -> None:
|
|
120
143
|
matrix = np.eye(4)
|
|
121
144
|
matrix[0, 0] = -1.0 # orthonormal, but det == -1
|
|
@@ -350,9 +350,9 @@ class TestLoadRtStructDuplicateNames:
|
|
|
350
350
|
``rt_utils.RTStruct.get_roi_mask_by_name`` matches the *first*
|
|
351
351
|
``StructureSetROISequence`` entry with a given name, which is a real risk
|
|
352
352
|
for TPS exports (duplicate ROI names are not invalid DICOM). These tests
|
|
353
|
-
stub out ``RTStructBuilder.create_from``
|
|
354
|
-
|
|
355
|
-
|
|
353
|
+
stub out ``RTStructBuilder.create_from`` with a minimal fake object
|
|
354
|
+
exposing only what ``load_rt_struct`` reads, so no on-disk DICOM series
|
|
355
|
+
is needed.
|
|
356
356
|
"""
|
|
357
357
|
|
|
358
358
|
class _FakeRoi:
|
|
@@ -403,7 +403,6 @@ class TestLoadRtStructDuplicateNames:
|
|
|
403
403
|
monkeypatch.setattr(
|
|
404
404
|
rtstruct_io.RTStructBuilder, "create_from", lambda **kw: rtstruct
|
|
405
405
|
)
|
|
406
|
-
monkeypatch.setattr(rtstruct_io.pydicom, "dcmread", lambda *a, **kw: ds)
|
|
407
406
|
|
|
408
407
|
result = rtstruct_io.load_rt_struct(ct_dir="/ct", rtstruct_path="/rs.dcm")
|
|
409
408
|
|
|
@@ -441,7 +440,6 @@ class TestLoadRtStructDuplicateNames:
|
|
|
441
440
|
monkeypatch.setattr(
|
|
442
441
|
rtstruct_io.RTStructBuilder, "create_from", lambda **kw: rtstruct
|
|
443
442
|
)
|
|
444
|
-
monkeypatch.setattr(rtstruct_io.pydicom, "dcmread", lambda *a, **kw: ds)
|
|
445
443
|
|
|
446
444
|
rtstruct_io.load_rt_struct(ct_dir="/ct", rtstruct_path="/rs.dcm")
|
|
447
445
|
|
|
@@ -474,13 +472,21 @@ class TestLoadRtStructMissingSequences:
|
|
|
474
472
|
"create_from",
|
|
475
473
|
lambda **kw: self._FakeRTStruct(ds),
|
|
476
474
|
)
|
|
477
|
-
monkeypatch.setattr(rtstruct_io.pydicom, "dcmread", lambda *a, **kw: ds)
|
|
478
475
|
|
|
479
476
|
def test_missing_roi_contour_sequence_returns_empty(self, monkeypatch) -> None:
|
|
480
477
|
self._patch(monkeypatch, self._EmptyDs())
|
|
481
478
|
result = rtstruct_io.load_rt_struct(ct_dir="/ct", rtstruct_path="/rs.dcm")
|
|
482
479
|
assert result == {}
|
|
483
480
|
|
|
481
|
+
def test_a_malformed_contour_item_is_skipped(self, monkeypatch) -> None:
|
|
482
|
+
class _Ds:
|
|
483
|
+
StructureSetROISequence: list = []
|
|
484
|
+
ROIContourSequence = [object()] # no ReferencedROINumber
|
|
485
|
+
|
|
486
|
+
self._patch(monkeypatch, _Ds())
|
|
487
|
+
result = rtstruct_io.load_rt_struct(ct_dir="/ct", rtstruct_path="/rs.dcm")
|
|
488
|
+
assert result == {}
|
|
489
|
+
|
|
484
490
|
def test_an_unreadable_file_still_raises_rtstruct_load_error(
|
|
485
491
|
self, monkeypatch
|
|
486
492
|
) -> None:
|
|
@@ -720,6 +720,13 @@ class TestResampleRequiresAPrimaryImage:
|
|
|
720
720
|
with pytest.raises(RuntimeError, match="no primary image"):
|
|
721
721
|
state.set_secondary_image_data(self._small_image())
|
|
722
722
|
|
|
723
|
+
def test_a_failed_secondary_leaves_no_source_behind(self) -> None:
|
|
724
|
+
state = SliceViewerState()
|
|
725
|
+
with pytest.raises(RuntimeError):
|
|
726
|
+
state.set_secondary_image_data(self._small_image())
|
|
727
|
+
assert state.secondary_source_image is None
|
|
728
|
+
assert state.secondary_image is None
|
|
729
|
+
|
|
723
730
|
def test_clearing_the_secondary_without_a_primary_is_allowed(self) -> None:
|
|
724
731
|
state = SliceViewerState()
|
|
725
732
|
state.set_secondary_image_data(None) # must not raise
|
|
@@ -756,3 +763,31 @@ class TestDeletingTheSelectedRoi:
|
|
|
756
763
|
state.delete_contour(other)
|
|
757
764
|
|
|
758
765
|
assert state.selected_roi_number == keep
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
class TestResampleOntoTheSameGrid:
|
|
769
|
+
"""An image already on the primary grid is not resampled again.
|
|
770
|
+
|
|
771
|
+
A 4DCT phase is resampled once into the LRU cache and then handed to
|
|
772
|
+
``set_secondary_image_data``, which used to resample it a second time
|
|
773
|
+
on every phase switch.
|
|
774
|
+
"""
|
|
775
|
+
|
|
776
|
+
def test_identity_on_the_primary_grid_returns_the_image(self) -> None:
|
|
777
|
+
state = make_state_with_image()
|
|
778
|
+
image = sitk.Image(state.primary_image)
|
|
779
|
+
assert state.get_resampled_image(image) is image
|
|
780
|
+
|
|
781
|
+
def test_a_transform_still_resamples(self) -> None:
|
|
782
|
+
state = make_state_with_image()
|
|
783
|
+
image = sitk.Image(state.primary_image)
|
|
784
|
+
moved = state.get_resampled_image(
|
|
785
|
+
image, transform=sitk.TranslationTransform(3, (1.0, 0.0, 0.0))
|
|
786
|
+
)
|
|
787
|
+
assert moved is not image
|
|
788
|
+
|
|
789
|
+
def test_another_grid_still_resamples(self) -> None:
|
|
790
|
+
state = make_state_with_image()
|
|
791
|
+
image = sitk.Image(state.primary_image)
|
|
792
|
+
image.SetOrigin(tuple(v + 0.5 for v in image.GetOrigin()))
|
|
793
|
+
assert state.get_resampled_image(image) is not image
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/bbox_handler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|