tk-rt-viewer 2.3.1__tar.gz → 2.3.3__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.3}/.github/workflows/ci.yml +2 -2
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/CHANGELOG.md +36 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/PKG-INFO +6 -1
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/README.md +5 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/__init__.py +1 -1
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +7 -9
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/brush_handler.py +5 -9
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/crosshair_handler.py +1 -3
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/viewer_events.py +3 -4
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/geometry.py +21 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/io.py +16 -17
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/reg_io.py +4 -1
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/params.py +5 -7
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/dvh.py +4 -7
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/isodose.py +1 -1
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/layout.py +14 -4
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/view_axes.py +4 -2
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/roi_operations.py +6 -25
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rtstruct_io.py +47 -13
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/phase_manager.py +1 -4
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/secondary_manager.py +13 -3
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/viewer_state.py +16 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/viewer.py +51 -78
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_brush_handler.py +2 -2
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_reg_io.py +23 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_rtstruct_io.py +117 -8
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_viewer_state.py +35 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/.gitattributes +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/.gitignore +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/LICENSE +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/pyproject.toml +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/__init__.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/bbox_handler.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/rect_drag.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/events.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/isodose_levels.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/protocols.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/py.typed +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/__init__.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/deformable.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/errors.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/rigid.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/session.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/template.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/__init__.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/blit_compositor.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/contour_overlay.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/drawing_manager.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/image_layer.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/render.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/__init__.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/dose_manager.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/roi_editor.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/roi_manager.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/structure_set.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/viewer_cache.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/window_level.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_bbox3d.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_bbox3d_handler.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_event_controllers.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_geometry.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_io.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_isodose_levels.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_performance_opt.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_registration.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_render.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_rendering.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_roi_operations.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_state_extensions.py +0 -0
- {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/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,42 @@ 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.3]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `rtstruct_io.mask2rtstruct` and `rtstruct_io.save_structure_set` take a
|
|
12
|
+
keyword-only `add_roi_kwargs` mapping, forwarded to rt-utils'
|
|
13
|
+
`RTStruct.add_roi` for every ROI. Callers can now set
|
|
14
|
+
`approximate_contours`, `use_pin_hole`, `roi_generation_algorithm`,
|
|
15
|
+
`description`, etc. `mask`, `color` and `name` still come from the
|
|
16
|
+
structures and raise `ValueError` if given here. The default (`None`)
|
|
17
|
+
keeps the previous behaviour.
|
|
18
|
+
|
|
19
|
+
## [2.3.2]
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- `reg_io.save_registration` wrote matrix values longer than the 16
|
|
24
|
+
characters the DS value representation allows (a rotation cosine prints as
|
|
25
|
+
18-19), so strict readers rejected the file. Values are now formatted to
|
|
26
|
+
fit.
|
|
27
|
+
- A `set_secondary_image_data` call that raised (no primary image) left its
|
|
28
|
+
image behind as `secondary_source_image`.
|
|
29
|
+
- `mypy src/tk_rt_viewer` failed with Matplotlib 3.11 (two errors in
|
|
30
|
+
`rendering/view_axes.py` and `rendering/isodose.py`). Annotations only.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- `SliceViewerState.get_resampled_image` returns an image already on the
|
|
35
|
+
primary grid as-is when no transform is given. Switching between cached
|
|
36
|
+
4DCT phases no longer resamples the volume again.
|
|
37
|
+
- `rtstruct_io.load_rt_struct` reads the RT-STRUCT file once instead of
|
|
38
|
+
twice, and skips a malformed `ROIContourSequence` item with a warning.
|
|
39
|
+
- Internal cleanup: `geometry.mask_bounds` replaces two copies of the mask
|
|
40
|
+
bounding-box code; duplicated series reading, scheduling and box-patch
|
|
41
|
+
code were merged.
|
|
42
|
+
|
|
7
43
|
## [2.3.1]
|
|
8
44
|
|
|
9
45
|
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.3
|
|
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
|
|
@@ -374,6 +374,11 @@ written = save_structure_set(
|
|
|
374
374
|
)
|
|
375
375
|
```
|
|
376
376
|
|
|
377
|
+
Pass `add_roi_kwargs` to forward options to rt-utils' `RTStruct.add_roi`
|
|
378
|
+
for every ROI, e.g. `add_roi_kwargs={"approximate_contours": False}` to
|
|
379
|
+
keep every contour vertex instead of the simplified outline. `mask2rtstruct`
|
|
380
|
+
accepts the same keyword.
|
|
381
|
+
|
|
377
382
|
`rt_utils.RTStruct.save` silently appends `.dcm` to a path that does not
|
|
378
383
|
already end with it, with no way to opt out; `mask2rtstruct` resolves
|
|
379
384
|
against that internally so its `exists()` check, its log messages, and its
|
|
@@ -337,6 +337,11 @@ written = save_structure_set(
|
|
|
337
337
|
)
|
|
338
338
|
```
|
|
339
339
|
|
|
340
|
+
Pass `add_roi_kwargs` to forward options to rt-utils' `RTStruct.add_roi`
|
|
341
|
+
for every ROI, e.g. `add_roi_kwargs={"approximate_contours": False}` to
|
|
342
|
+
keep every contour vertex instead of the simplified outline. `mask2rtstruct`
|
|
343
|
+
accepts the same keyword.
|
|
344
|
+
|
|
340
345
|
`rt_utils.RTStruct.save` silently appends `.dcm` to a path that does not
|
|
341
346
|
already end with it, with no way to opt out; `mask2rtstruct` resolves
|
|
342
347
|
against that internally so its `exists()` check, its log messages, and its
|
{tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/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.3}/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.3}/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.3}/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
|
|
|
@@ -55,18 +55,18 @@ import re
|
|
|
55
55
|
from collections.abc import Iterable, Iterator, Sequence
|
|
56
56
|
from concurrent.futures import ThreadPoolExecutor
|
|
57
57
|
from dataclasses import dataclass, field, replace
|
|
58
|
-
from typing import TypedDict
|
|
58
|
+
from typing import TypedDict, cast
|
|
59
59
|
|
|
60
60
|
import numpy as np
|
|
61
61
|
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
|
-
image = reader.Execute()
|
|
675
|
+
image = cast(sitk.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
|