tk-rt-viewer 2.2.1__tar.gz → 2.3.0__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.2.1 → tk_rt_viewer-2.3.0}/CHANGELOG.md +28 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/PKG-INFO +30 -3
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/README.md +29 -2
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/__init__.py +1 -1
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/viewer_events.py +57 -3
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/image_layer.py +7 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/layout.py +15 -7
- tk_rt_viewer-2.3.0/src/tk_rt_viewer/rendering/view_axes.py +188 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/viewer.py +85 -8
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_event_controllers.py +131 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_rendering.py +2 -2
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/.gitattributes +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/.github/workflows/ci.yml +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/.gitignore +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/LICENSE +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/pyproject.toml +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/__init__.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/bbox_handler.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/brush_handler.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/crosshair_handler.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/rect_drag.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/events.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/geometry.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/io.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/isodose_levels.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/protocols.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/py.typed +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/reg_io.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/registration/__init__.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/registration/deformable.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/registration/errors.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/registration/params.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/registration/rigid.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/registration/session.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/registration/template.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/__init__.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/blit_compositor.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/contour_overlay.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/drawing_manager.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/dvh.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/isodose.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/render.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/roi_operations.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rtstruct_io.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/__init__.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/dose_manager.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/phase_manager.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/roi_editor.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/roi_manager.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/secondary_manager.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/structure_set.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/viewer_cache.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/viewer_state.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/window_level.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_bbox3d.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_bbox3d_handler.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_brush_handler.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_geometry.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_io.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_isodose_levels.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_performance_opt.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_reg_io.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_registration.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_render.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_roi_operations.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_rtstruct_io.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_state_extensions.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_state_managers.py +0 -0
- {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_viewer_state.py +0 -0
|
@@ -4,6 +4,34 @@ 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.0]
|
|
8
|
+
|
|
9
|
+
Zoom per view, a reversible wheel, and views that fill the figure.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Ctrl + mouse wheel zooms** the view under the pointer, about the
|
|
14
|
+
pointer, from 1× (fitted) to 20×. Each view keeps its own zoom; a resize
|
|
15
|
+
keeps zoom and centre. Works while the brush tool is active.
|
|
16
|
+
- **`DicomViewer.set_zoom(axis, zoom, anchor=None)`**, **`get_zoom(axis)`**
|
|
17
|
+
and **`reset_zoom(axis=None)`**.
|
|
18
|
+
- **`DicomViewer(..., invert_scroll=False)`** and the
|
|
19
|
+
**`DicomViewer.scroll_inverted`** property reverse the wheel for slice
|
|
20
|
+
scrolling (`ViewerEventHandler.invert_scroll`). Brush resizing, zoom and
|
|
21
|
+
the arrow keys keep their direction.
|
|
22
|
+
- **`ImageViewAxes`** (`tk_rt_viewer.rendering.view_axes`): the Axes class of
|
|
23
|
+
every image view. It fills its layout cell and keeps a 1:1 data aspect
|
|
24
|
+
through its limits; `set_aspect` is pinned to `"auto"` on it.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- The image views no longer shrink their box to the slice's aspect
|
|
29
|
+
(`adjustable="box"`), so a zoomed slice uses the whole layout cell.
|
|
30
|
+
- The constrained-layout padding around and between the views is reduced
|
|
31
|
+
to 1 pt (`DicomViewer._LAYOUT_PAD_INCHES` / `_LAYOUT_SPACE`). The figure is
|
|
32
|
+
created with `layout="constrained"` unless `fig_kwargs` sets `layout`,
|
|
33
|
+
`constrained_layout` or `tight_layout`.
|
|
34
|
+
|
|
7
35
|
## [2.2.0]
|
|
8
36
|
|
|
9
37
|
Loading a series picked from a scan now reads only that series' files.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tk-rt-viewer
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
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
|
|
@@ -58,7 +58,8 @@ The distribution name on PyPI is `tk-rt-viewer`; the import package is
|
|
|
58
58
|
- **Blit-based rendering** — Idle-driven blit updates via `DrawingManager`; redraw requests are coalesced into a single Tk `after_idle` callback instead of a fixed-interval polling timer.
|
|
59
59
|
- **Observer-pattern state management** — All view state lives in `SliceViewerState`; the widget reacts to changes without polling.
|
|
60
60
|
- **SimpleITK-native coordinates** — Physical LPS coordinates, origin, spacing, and direction cosines are preserved throughout; axis reordering between SimpleITK and NumPy conventions is handled internally by the library.
|
|
61
|
-
- **Interactive navigation** — Crosshair drag, mouse wheel, and keyboard (↑ / ↓ / PageUp / PageDown).
|
|
61
|
+
- **Interactive navigation** — Crosshair drag, mouse wheel (direction reversible), and keyboard (↑ / ↓ / PageUp / PageDown).
|
|
62
|
+
- **Per-view zoom** — Ctrl + mouse wheel zooms the view under the pointer about the pointer; each view keeps its own zoom factor. The views fill their layout cells, so a zoomed slice uses the whole cell.
|
|
62
63
|
- **Independent window / level per image** — The primary and secondary images each carry their own display window, so a PET, MR, or dose overlay can be windowed without disturbing the CT underneath. The secondary follows the primary until an override is set. Right-click drag adjusts whichever image is targeted: horizontal → window width (WW), vertical → window centre (WL).
|
|
63
64
|
- **RT-STRUCT support** — ROI masks stored in `StructureSet` (keyed by integer ROI number); contour overlay with optional semi-transparent fill; brush tool for mask editing.
|
|
64
65
|
- **ROI operations** — Shape-based inter-slice interpolation, true Euclidean margins (uniform or 6-direction anisotropic), Gaussian smoothing, and boolean operations (union / intersection / subtraction).
|
|
@@ -132,7 +133,8 @@ tk_rt_viewer/
|
|
|
132
133
|
│ ├── render.py # RGBA colormap LUT and window/level helpers
|
|
133
134
|
│ ├── isodose.py # IsoDoseOverlay (fill bands + contour lines)
|
|
134
135
|
│ ├── dvh.py # DvhPanel (cumulative DVH panel)
|
|
135
|
-
│
|
|
136
|
+
│ ├── layout.py # LayoutManager (single / mpr / mpr_wide layouts)
|
|
137
|
+
│ └── view_axes.py # ImageViewAxes (fills its cell, 1:1 aspect, zoom)
|
|
136
138
|
├── registration/
|
|
137
139
|
│ ├── session.py # RegistrationSession, cropping, resampling
|
|
138
140
|
│ ├── params.py # RigidParams <-> transforms (Vert/Lat/Long/...)
|
|
@@ -749,6 +751,31 @@ box tool, crosshair, contours, isodose) works unchanged in `"single"` mode
|
|
|
749
751
|
against the `"axial"` key — host code does not need a separate code path
|
|
750
752
|
for it.
|
|
751
753
|
|
|
754
|
+
## Zoom and wheel direction
|
|
755
|
+
|
|
756
|
+
Ctrl + mouse wheel zooms the view under the pointer, keeping the point under
|
|
757
|
+
the pointer in place. Each view is zoomed independently, from 1× (the whole
|
|
758
|
+
slice fitted to the view) up to 20×, and a zoomed view cannot be moved off
|
|
759
|
+
the slice. A figure resize keeps each view's zoom and centre; loading a new
|
|
760
|
+
primary image or switching the layout starts fitted again. Ctrl + wheel zooms
|
|
761
|
+
even while the brush tool is active (the plain wheel still resizes the brush).
|
|
762
|
+
|
|
763
|
+
```python
|
|
764
|
+
viewer.set_zoom("axial", 2.5) # about the view centre
|
|
765
|
+
viewer.set_zoom("coronal", 4.0, anchor=(x, z)) # keep (x, z) in place
|
|
766
|
+
viewer.get_zoom("axial") # -> 2.5
|
|
767
|
+
viewer.reset_zoom() # fit every view again
|
|
768
|
+
|
|
769
|
+
# Reverse the wheel for slice scrolling (brush size and zoom are unaffected)
|
|
770
|
+
viewer = DicomViewer(parent, state=state, invert_scroll=True)
|
|
771
|
+
viewer.scroll_inverted = False # or toggle at run time
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
The image views are `ImageViewAxes` (`tk_rt_viewer.rendering.view_axes`):
|
|
775
|
+
they always fill their layout cell and keep a 1:1 data aspect by adjusting
|
|
776
|
+
their limits instead of shrinking their box. At 1× the slice is letterboxed
|
|
777
|
+
inside the cell where their aspect ratios differ.
|
|
778
|
+
|
|
752
779
|
## Embedding in a larger application
|
|
753
780
|
|
|
754
781
|
`DicomViewer` is a `ttk.Frame` subclass, so it can be packed, gridded, or
|
|
@@ -21,7 +21,8 @@ The distribution name on PyPI is `tk-rt-viewer`; the import package is
|
|
|
21
21
|
- **Blit-based rendering** — Idle-driven blit updates via `DrawingManager`; redraw requests are coalesced into a single Tk `after_idle` callback instead of a fixed-interval polling timer.
|
|
22
22
|
- **Observer-pattern state management** — All view state lives in `SliceViewerState`; the widget reacts to changes without polling.
|
|
23
23
|
- **SimpleITK-native coordinates** — Physical LPS coordinates, origin, spacing, and direction cosines are preserved throughout; axis reordering between SimpleITK and NumPy conventions is handled internally by the library.
|
|
24
|
-
- **Interactive navigation** — Crosshair drag, mouse wheel, and keyboard (↑ / ↓ / PageUp / PageDown).
|
|
24
|
+
- **Interactive navigation** — Crosshair drag, mouse wheel (direction reversible), and keyboard (↑ / ↓ / PageUp / PageDown).
|
|
25
|
+
- **Per-view zoom** — Ctrl + mouse wheel zooms the view under the pointer about the pointer; each view keeps its own zoom factor. The views fill their layout cells, so a zoomed slice uses the whole cell.
|
|
25
26
|
- **Independent window / level per image** — The primary and secondary images each carry their own display window, so a PET, MR, or dose overlay can be windowed without disturbing the CT underneath. The secondary follows the primary until an override is set. Right-click drag adjusts whichever image is targeted: horizontal → window width (WW), vertical → window centre (WL).
|
|
26
27
|
- **RT-STRUCT support** — ROI masks stored in `StructureSet` (keyed by integer ROI number); contour overlay with optional semi-transparent fill; brush tool for mask editing.
|
|
27
28
|
- **ROI operations** — Shape-based inter-slice interpolation, true Euclidean margins (uniform or 6-direction anisotropic), Gaussian smoothing, and boolean operations (union / intersection / subtraction).
|
|
@@ -95,7 +96,8 @@ tk_rt_viewer/
|
|
|
95
96
|
│ ├── render.py # RGBA colormap LUT and window/level helpers
|
|
96
97
|
│ ├── isodose.py # IsoDoseOverlay (fill bands + contour lines)
|
|
97
98
|
│ ├── dvh.py # DvhPanel (cumulative DVH panel)
|
|
98
|
-
│
|
|
99
|
+
│ ├── layout.py # LayoutManager (single / mpr / mpr_wide layouts)
|
|
100
|
+
│ └── view_axes.py # ImageViewAxes (fills its cell, 1:1 aspect, zoom)
|
|
99
101
|
├── registration/
|
|
100
102
|
│ ├── session.py # RegistrationSession, cropping, resampling
|
|
101
103
|
│ ├── params.py # RigidParams <-> transforms (Vert/Lat/Long/...)
|
|
@@ -712,6 +714,31 @@ box tool, crosshair, contours, isodose) works unchanged in `"single"` mode
|
|
|
712
714
|
against the `"axial"` key — host code does not need a separate code path
|
|
713
715
|
for it.
|
|
714
716
|
|
|
717
|
+
## Zoom and wheel direction
|
|
718
|
+
|
|
719
|
+
Ctrl + mouse wheel zooms the view under the pointer, keeping the point under
|
|
720
|
+
the pointer in place. Each view is zoomed independently, from 1× (the whole
|
|
721
|
+
slice fitted to the view) up to 20×, and a zoomed view cannot be moved off
|
|
722
|
+
the slice. A figure resize keeps each view's zoom and centre; loading a new
|
|
723
|
+
primary image or switching the layout starts fitted again. Ctrl + wheel zooms
|
|
724
|
+
even while the brush tool is active (the plain wheel still resizes the brush).
|
|
725
|
+
|
|
726
|
+
```python
|
|
727
|
+
viewer.set_zoom("axial", 2.5) # about the view centre
|
|
728
|
+
viewer.set_zoom("coronal", 4.0, anchor=(x, z)) # keep (x, z) in place
|
|
729
|
+
viewer.get_zoom("axial") # -> 2.5
|
|
730
|
+
viewer.reset_zoom() # fit every view again
|
|
731
|
+
|
|
732
|
+
# Reverse the wheel for slice scrolling (brush size and zoom are unaffected)
|
|
733
|
+
viewer = DicomViewer(parent, state=state, invert_scroll=True)
|
|
734
|
+
viewer.scroll_inverted = False # or toggle at run time
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
The image views are `ImageViewAxes` (`tk_rt_viewer.rendering.view_axes`):
|
|
738
|
+
they always fill their layout cell and keep a 1:1 data aspect by adjusting
|
|
739
|
+
their limits instead of shrinking their box. At 1× the slice is letterboxed
|
|
740
|
+
inside the cell where their aspect ratios differ.
|
|
741
|
+
|
|
715
742
|
## Embedding in a larger application
|
|
716
743
|
|
|
717
744
|
`DicomViewer` is a `ttk.Frame` subclass, so it can be packed, gridded, or
|
{tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/viewer_events.py
RENAMED
|
@@ -21,18 +21,29 @@ Hover tracking:
|
|
|
21
21
|
Scroll debounce:
|
|
22
22
|
Wheel steps are accumulated for ``SCROLL_DEBOUNCE_MS`` and applied in one
|
|
23
23
|
``set_index`` call, on the Tk main thread. Brush resizing is immediate.
|
|
24
|
+
|
|
25
|
+
Wheel priority:
|
|
26
|
+
1. Ctrl held: zoom the view under the pointer about the pointer
|
|
27
|
+
(:class:`~tk_rt_viewer.rendering.view_axes.ImageViewAxes`), even while
|
|
28
|
+
the brush is active
|
|
29
|
+
2. Brush tool: resize the brush
|
|
30
|
+
3. Otherwise: scroll slices; ``invert_scroll`` flips the direction
|
|
24
31
|
"""
|
|
25
32
|
|
|
26
33
|
import numpy as np
|
|
27
34
|
|
|
28
35
|
from .. import events
|
|
29
36
|
from ..protocols import ViewerHost
|
|
37
|
+
from ..rendering.view_axes import ImageViewAxes
|
|
30
38
|
from ..state.viewer_state import SliceViewerState
|
|
31
39
|
from .bbox3d_handler import Bbox3dEventHandler
|
|
32
40
|
from .bbox_handler import BboxEventHandler
|
|
33
41
|
from .brush_handler import BrushEventHandler
|
|
34
42
|
from .crosshair_handler import CrosshairEventHandler
|
|
35
43
|
|
|
44
|
+
#: Zoom ratio per Ctrl + wheel notch.
|
|
45
|
+
ZOOM_STEP: float = 1.25
|
|
46
|
+
|
|
36
47
|
#: Debounce window (ms) for batching scroll events; short enough to feel
|
|
37
48
|
#: immediate, long enough to coalesce a wheel flick.
|
|
38
49
|
SCROLL_DEBOUNCE_MS: int = 30
|
|
@@ -58,9 +69,18 @@ _MIN_WINDOW_WIDTH: float = 1.0
|
|
|
58
69
|
class ViewerEventHandler:
|
|
59
70
|
"""Dispatch matplotlib canvas events to specialised sub-handlers."""
|
|
60
71
|
|
|
61
|
-
def __init__(
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
state: SliceViewerState,
|
|
75
|
+
viewer: ViewerHost,
|
|
76
|
+
*,
|
|
77
|
+
invert_scroll: bool = False,
|
|
78
|
+
) -> None:
|
|
62
79
|
self.state = state
|
|
63
80
|
self.viewer = viewer
|
|
81
|
+
#: Reverse the wheel direction for slice scrolling only (the brush
|
|
82
|
+
#: size and zoom keep theirs)
|
|
83
|
+
self.invert_scroll: bool = invert_scroll
|
|
64
84
|
|
|
65
85
|
self._current_axis: str = ""
|
|
66
86
|
|
|
@@ -130,7 +150,11 @@ class ViewerEventHandler:
|
|
|
130
150
|
# Scroll
|
|
131
151
|
# ------------------------------------------------------------------
|
|
132
152
|
def on_scroll(self, event) -> None:
|
|
133
|
-
"""
|
|
153
|
+
"""Zoom (Ctrl), resize the brush, or accumulate a debounced slice scroll."""
|
|
154
|
+
if self._ctrl_held(event):
|
|
155
|
+
self._zoom_at_pointer(event)
|
|
156
|
+
return
|
|
157
|
+
|
|
134
158
|
if self.state.brush_tool_active and self._current_axis:
|
|
135
159
|
self.brush_handler.handle_scroll(event)
|
|
136
160
|
return
|
|
@@ -145,7 +169,8 @@ class ViewerEventHandler:
|
|
|
145
169
|
self._flush_scroll()
|
|
146
170
|
|
|
147
171
|
self._scroll_axis = axis
|
|
148
|
-
self.
|
|
172
|
+
direction = -1 if self.invert_scroll else 1
|
|
173
|
+
self._scroll_accum += direction * int(np.sign(event.step))
|
|
149
174
|
|
|
150
175
|
self._cancel_scroll_timer()
|
|
151
176
|
handle = self.viewer.schedule(SCROLL_DEBOUNCE_MS, self._flush_scroll)
|
|
@@ -155,6 +180,35 @@ class ViewerEventHandler:
|
|
|
155
180
|
return
|
|
156
181
|
self._scroll_handle = handle
|
|
157
182
|
|
|
183
|
+
def _zoom_at_pointer(self, event) -> None:
|
|
184
|
+
"""Zoom the view under the pointer one notch, about the pointer."""
|
|
185
|
+
ax = self.viewer.axes_map.get(self._current_axis)
|
|
186
|
+
if (
|
|
187
|
+
not isinstance(ax, ImageViewAxes)
|
|
188
|
+
or self.state.primary_image is None
|
|
189
|
+
or event.xdata is None
|
|
190
|
+
or event.ydata is None
|
|
191
|
+
or not event.step
|
|
192
|
+
):
|
|
193
|
+
return
|
|
194
|
+
factor = ZOOM_STEP ** float(np.sign(event.step))
|
|
195
|
+
ax.zoom_to(ax.zoom_factor() * factor, anchor=(event.xdata, event.ydata))
|
|
196
|
+
self.viewer.refresh_canvas()
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _ctrl_held(event) -> bool:
|
|
200
|
+
"""Return whether Ctrl is held during *event*.
|
|
201
|
+
|
|
202
|
+
``modifiers`` (Matplotlib >= 3.7) is read from the GUI event itself;
|
|
203
|
+
``key`` only reflects the last key event the canvas received, so it
|
|
204
|
+
is a fallback.
|
|
205
|
+
"""
|
|
206
|
+
modifiers = getattr(event, "modifiers", None)
|
|
207
|
+
if modifiers is not None:
|
|
208
|
+
return "ctrl" in modifiers
|
|
209
|
+
key = event.key or ""
|
|
210
|
+
return key in ("control", "ctrl") or key.startswith("ctrl+")
|
|
211
|
+
|
|
158
212
|
def _cancel_scroll_timer(self) -> None:
|
|
159
213
|
"""Cancel the pending scroll-debounce callback, if any."""
|
|
160
214
|
if self._scroll_handle is None:
|
|
@@ -16,6 +16,7 @@ from matplotlib.image import AxesImage
|
|
|
16
16
|
|
|
17
17
|
from ..geometry import AXES
|
|
18
18
|
from .render import GRAY_LUT, build_cmap_lut, slice_to_rgba, window_level_to_clim
|
|
19
|
+
from .view_axes import ImageViewAxes
|
|
19
20
|
|
|
20
21
|
if TYPE_CHECKING:
|
|
21
22
|
from ..state.viewer_state import SliceViewerState
|
|
@@ -163,6 +164,10 @@ class ImageLayer:
|
|
|
163
164
|
|
|
164
165
|
Coronal / sagittal show superior (large z) at the top. Axial inverts y
|
|
165
166
|
so anterior is at the top, per radiological convention.
|
|
167
|
+
|
|
168
|
+
An :class:`ImageViewAxes` (every view the layout builds) keeps its
|
|
169
|
+
box filling the layout cell and conforms these limits to it; a plain
|
|
170
|
+
Axes shrinks its box to the slice's aspect instead.
|
|
166
171
|
"""
|
|
167
172
|
if axis in ("coronal", "sagittal"):
|
|
168
173
|
y_bottom, y_top = extent[2], extent[3]
|
|
@@ -171,6 +176,8 @@ class ImageLayer:
|
|
|
171
176
|
ax.set_xlim(extent[0], extent[1])
|
|
172
177
|
ax.set_ylim(y_bottom, y_top)
|
|
173
178
|
ax.set_aspect("equal", adjustable="box")
|
|
179
|
+
if isinstance(ax, ImageViewAxes):
|
|
180
|
+
ax.set_image_extent(extent)
|
|
174
181
|
|
|
175
182
|
def _update_secondary(
|
|
176
183
|
self,
|
|
@@ -7,11 +7,15 @@ from matplotlib.axes import Axes
|
|
|
7
7
|
from matplotlib.figure import Figure
|
|
8
8
|
|
|
9
9
|
from ..geometry import LAYOUT_MODES
|
|
10
|
+
from .view_axes import ImageViewAxes
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class LayoutManager:
|
|
13
14
|
"""Builds the Axes layout for a given mode inside a shared Figure.
|
|
14
15
|
|
|
16
|
+
The image views are :class:`ImageViewAxes`, which fill their cell and
|
|
17
|
+
support zooming; the DVH panel is a plain Axes.
|
|
18
|
+
|
|
15
19
|
Supported modes:
|
|
16
20
|
``"mpr_wide"`` — left column: large Axial; right column: Coronal /
|
|
17
21
|
Sagittal stacked (default, no DVH panel).
|
|
@@ -47,22 +51,22 @@ class LayoutManager:
|
|
|
47
51
|
``None`` except for ``"mpr"``.
|
|
48
52
|
"""
|
|
49
53
|
if mode == "single":
|
|
50
|
-
axs = {"axial": self.
|
|
54
|
+
axs = {"axial": self._add_view(111)}
|
|
51
55
|
dvh_ax = None
|
|
52
56
|
elif mode == "mpr_wide":
|
|
53
57
|
gs = gridspec.GridSpec(2, 2, figure=self._fig, width_ratios=[2, 1])
|
|
54
58
|
axs = {
|
|
55
|
-
"axial": self.
|
|
56
|
-
"coronal": self.
|
|
57
|
-
"sagittal": self.
|
|
59
|
+
"axial": self._add_view(gs[:, 0]),
|
|
60
|
+
"coronal": self._add_view(gs[0, 1]),
|
|
61
|
+
"sagittal": self._add_view(gs[1, 1]),
|
|
58
62
|
}
|
|
59
63
|
dvh_ax = None
|
|
60
64
|
elif mode == "mpr":
|
|
61
65
|
gs = gridspec.GridSpec(2, 2, figure=self._fig)
|
|
62
66
|
axs = {
|
|
63
|
-
"axial": self.
|
|
64
|
-
"coronal": self.
|
|
65
|
-
"sagittal": self.
|
|
67
|
+
"axial": self._add_view(gs[0, 0]),
|
|
68
|
+
"coronal": self._add_view(gs[1, 0]),
|
|
69
|
+
"sagittal": self._add_view(gs[1, 1]),
|
|
66
70
|
}
|
|
67
71
|
dvh_ax = self._fig.add_subplot(gs[0, 1])
|
|
68
72
|
self._style_dvh_axes(dvh_ax)
|
|
@@ -77,3 +81,7 @@ class LayoutManager:
|
|
|
77
81
|
ax.set_axis_off()
|
|
78
82
|
|
|
79
83
|
return axs, dvh_ax
|
|
84
|
+
|
|
85
|
+
def _add_view(self, spec) -> ImageViewAxes:
|
|
86
|
+
"""Add an image view in the grid cell *spec*."""
|
|
87
|
+
return self._fig.add_subplot(spec, axes_class=ImageViewAxes)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""view_axes.py — Axes for an image view: fills its cell, keeps 1:1, zooms.
|
|
2
|
+
|
|
3
|
+
Matplotlib keeps an equal data aspect either by shrinking the Axes box
|
|
4
|
+
(``adjustable="box"``, which leaves empty bands in the layout cell) or by
|
|
5
|
+
widening the limits (``adjustable="datalim"``, which logs a warning whenever
|
|
6
|
+
the limits were set explicitly, i.e. after every zoom). :class:`ImageViewAxes`
|
|
7
|
+
does the latter itself: the box always fills its cell and the limits are
|
|
8
|
+
conformed to the box on every draw.
|
|
9
|
+
|
|
10
|
+
Zoom is relative to the "fit" view, in which the whole slice just fits the
|
|
11
|
+
box. It is read back from the limits, so a Matplotlib toolbar zoom or pan is
|
|
12
|
+
picked up as well; after a resize the zoom factor and centre are kept.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from matplotlib.axes import Axes
|
|
16
|
+
|
|
17
|
+
#: Fit-to-view; zooming out never shrinks the slice below this.
|
|
18
|
+
MIN_ZOOM: float = 1.0
|
|
19
|
+
#: Upper bound on the zoom factor.
|
|
20
|
+
MAX_ZOOM: float = 20.0
|
|
21
|
+
|
|
22
|
+
# Relative span mismatch below which the limits are left alone (avoids
|
|
23
|
+
# re-setting them every draw for floating-point noise)
|
|
24
|
+
_TOLERANCE: float = 1e-6
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ImageViewAxes(Axes):
|
|
28
|
+
"""Axes that fills its layout cell and keeps a 1:1 data aspect via its limits.
|
|
29
|
+
|
|
30
|
+
:meth:`set_aspect` is pinned to ``"auto"`` (``imshow`` would otherwise
|
|
31
|
+
switch the box to ``aspect="equal"``); the equal aspect is enforced by
|
|
32
|
+
:meth:`apply_aspect` through the limits instead.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
36
|
+
super().__init__(*args, **kwargs)
|
|
37
|
+
# (x0, x1, y0, y1) sorted; None until an image is shown
|
|
38
|
+
self._image_extent: tuple[float, float, float, float] | None = None
|
|
39
|
+
# (xlim, ylim, box ratio) left by the last conform, to tell a resize
|
|
40
|
+
# (box changed, limits untouched) from a limit change by someone else
|
|
41
|
+
self._conformed: tuple | None = None
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
# Matplotlib hooks
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
def set_aspect(self, aspect, adjustable=None, anchor=None, share=False) -> None:
|
|
47
|
+
"""Keep the box free; the equal aspect is kept through the limits."""
|
|
48
|
+
super().set_aspect("auto", adjustable=adjustable, anchor=anchor, share=share)
|
|
49
|
+
|
|
50
|
+
def apply_aspect(self, position=None) -> None:
|
|
51
|
+
"""Place the box, then conform the limits to its aspect."""
|
|
52
|
+
super().apply_aspect(position)
|
|
53
|
+
self._conform_limits()
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Public API
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
def set_image_extent(self, extent: tuple[float, float, float, float]) -> None:
|
|
59
|
+
"""Record the slice extent that bounds zooming and panning."""
|
|
60
|
+
x0, x1, y0, y1 = extent
|
|
61
|
+
self._image_extent = (min(x0, x1), max(x0, x1), min(y0, y1), max(y0, y1))
|
|
62
|
+
self._conformed = None
|
|
63
|
+
|
|
64
|
+
def zoom_factor(self) -> float:
|
|
65
|
+
"""Return the zoom relative to the fit view (1.0 without an image)."""
|
|
66
|
+
fit = self._fit_spans(self._box_ratio())
|
|
67
|
+
span = self._spans()[0]
|
|
68
|
+
if fit is None or span <= 0:
|
|
69
|
+
return 1.0
|
|
70
|
+
return fit[0] / span
|
|
71
|
+
|
|
72
|
+
def zoom_to(self, zoom: float, anchor: tuple[float, float] | None = None) -> None:
|
|
73
|
+
"""Zoom to *zoom*, keeping *anchor* (data coordinates) where it is on screen.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
zoom: Target factor, clipped to ``[MIN_ZOOM, MAX_ZOOM]``.
|
|
77
|
+
anchor: Point that stays put; ``None`` zooms about the view centre.
|
|
78
|
+
"""
|
|
79
|
+
fit = self._fit_spans(self._box_ratio())
|
|
80
|
+
if fit is None:
|
|
81
|
+
return
|
|
82
|
+
zoom = min(max(float(zoom), MIN_ZOOM), MAX_ZOOM)
|
|
83
|
+
new_x, new_y = fit[0] / zoom, fit[1] / zoom
|
|
84
|
+
(old_x, old_y), (cx, cy) = self._spans(), self._center()
|
|
85
|
+
if anchor is not None and old_x > 0 and old_y > 0:
|
|
86
|
+
cx = anchor[0] + (cx - anchor[0]) * new_x / old_x
|
|
87
|
+
cy = anchor[1] + (cy - anchor[1]) * new_y / old_y
|
|
88
|
+
self._set_view(cx, cy, new_x, new_y)
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# Internals
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
def _conform_limits(self) -> None:
|
|
94
|
+
"""Make the limits match the box aspect.
|
|
95
|
+
|
|
96
|
+
After a resize (box changed, limits as last conformed) the zoom
|
|
97
|
+
factor and centre are kept. Otherwise the narrower span is widened
|
|
98
|
+
about the centre, as ``adjustable="datalim"`` would.
|
|
99
|
+
"""
|
|
100
|
+
ratio = self._box_ratio()
|
|
101
|
+
if ratio is None:
|
|
102
|
+
return
|
|
103
|
+
(span_x, span_y), (cx, cy) = self._spans(), self._center()
|
|
104
|
+
if span_x <= 0 or span_y <= 0:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
limits = (self.get_xlim(), self.get_ylim())
|
|
108
|
+
previous = self._conformed
|
|
109
|
+
resized = (
|
|
110
|
+
previous is not None and previous[:2] == limits and previous[2] != ratio
|
|
111
|
+
)
|
|
112
|
+
old_fit = self._fit_spans(previous[2]) if resized else None
|
|
113
|
+
new_fit = self._fit_spans(ratio)
|
|
114
|
+
if old_fit is not None and new_fit is not None:
|
|
115
|
+
zoom = old_fit[0] / span_x
|
|
116
|
+
span_x, span_y = new_fit[0] / zoom, new_fit[1] / zoom
|
|
117
|
+
elif span_y < span_x * ratio:
|
|
118
|
+
span_y = span_x * ratio
|
|
119
|
+
else:
|
|
120
|
+
span_x = span_y / ratio
|
|
121
|
+
|
|
122
|
+
self._set_view(cx, cy, span_x, span_y)
|
|
123
|
+
self._conformed = (self.get_xlim(), self.get_ylim(), ratio)
|
|
124
|
+
|
|
125
|
+
def _set_view(self, cx: float, cy: float, span_x: float, span_y: float) -> None:
|
|
126
|
+
"""Set the limits around a centre, clamped to the slice, keeping inversion."""
|
|
127
|
+
if self._image_extent is not None:
|
|
128
|
+
x0, x1, y0, y1 = self._image_extent
|
|
129
|
+
cx = _clamp_center(cx, span_x, x0, x1)
|
|
130
|
+
cy = _clamp_center(cy, span_y, y0, y1)
|
|
131
|
+
xlim = _limits(cx, span_x, self.xaxis_inverted())
|
|
132
|
+
ylim = _limits(cy, span_y, self.yaxis_inverted())
|
|
133
|
+
if not _close(self.get_xlim(), xlim):
|
|
134
|
+
self.set_xlim(xlim, auto=None)
|
|
135
|
+
if not _close(self.get_ylim(), ylim):
|
|
136
|
+
self.set_ylim(ylim, auto=None)
|
|
137
|
+
|
|
138
|
+
def _box_ratio(self) -> float | None:
|
|
139
|
+
"""Return the box's height / width on screen, or ``None`` if it is empty."""
|
|
140
|
+
width, height = self.bbox.width, self.bbox.height
|
|
141
|
+
if width <= 0 or height <= 0:
|
|
142
|
+
return None
|
|
143
|
+
return height / width
|
|
144
|
+
|
|
145
|
+
def _fit_spans(self, ratio: float | None) -> tuple[float, float] | None:
|
|
146
|
+
"""Return the spans showing the whole slice in a box of *ratio*."""
|
|
147
|
+
if ratio is None or self._image_extent is None:
|
|
148
|
+
return None
|
|
149
|
+
x0, x1, y0, y1 = self._image_extent
|
|
150
|
+
width, height = x1 - x0, y1 - y0
|
|
151
|
+
if width <= 0 or height <= 0:
|
|
152
|
+
return None
|
|
153
|
+
span_x = max(width, height / ratio)
|
|
154
|
+
return span_x, span_x * ratio
|
|
155
|
+
|
|
156
|
+
def _spans(self) -> tuple[float, float]:
|
|
157
|
+
(xa, xb), (ya, yb) = self.get_xlim(), self.get_ylim()
|
|
158
|
+
return abs(xb - xa), abs(yb - ya)
|
|
159
|
+
|
|
160
|
+
def _center(self) -> tuple[float, float]:
|
|
161
|
+
(xa, xb), (ya, yb) = self.get_xlim(), self.get_ylim()
|
|
162
|
+
return (xa + xb) / 2, (ya + yb) / 2
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _clamp_center(center: float, span: float, low: float, high: float) -> float:
|
|
166
|
+
"""Keep a view of width *span* over ``[low, high]``.
|
|
167
|
+
|
|
168
|
+
A view wider than the slice is centred on it; a narrower one may not
|
|
169
|
+
slide past either edge.
|
|
170
|
+
"""
|
|
171
|
+
if span >= high - low:
|
|
172
|
+
return (low + high) / 2
|
|
173
|
+
return min(max(center, low + span / 2), high - span / 2)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _limits(center: float, span: float, inverted: bool) -> tuple[float, float]:
|
|
177
|
+
half = span / 2
|
|
178
|
+
return (
|
|
179
|
+
(center + half, center - half) if inverted else (center - half, center + half)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _close(a: tuple[float, float], b: tuple[float, float]) -> bool:
|
|
184
|
+
scale = max(abs(b[1] - b[0]), 1e-12)
|
|
185
|
+
return (
|
|
186
|
+
abs(a[0] - b[0]) <= _TOLERANCE * scale
|
|
187
|
+
and abs(a[1] - b[1]) <= _TOLERANCE * scale
|
|
188
|
+
)
|
|
@@ -21,9 +21,14 @@ Architecture:
|
|
|
21
21
|
|
|
22
22
|
Slice navigation:
|
|
23
23
|
- Drag a crosshair line.
|
|
24
|
-
- Mouse wheel over any view.
|
|
24
|
+
- Mouse wheel over any view (``scroll_inverted`` reverses it).
|
|
25
25
|
- Up / Down / PageUp / PageDown keys.
|
|
26
26
|
|
|
27
|
+
Zoom:
|
|
28
|
+
Ctrl + mouse wheel zooms the view under the pointer about the pointer,
|
|
29
|
+
each view independently (``set_zoom`` / ``get_zoom`` / ``reset_zoom``).
|
|
30
|
+
The views fill their layout cells; a new image or layout starts fitted.
|
|
31
|
+
|
|
27
32
|
Window / level:
|
|
28
33
|
Right-click drag: horizontal -> width, vertical -> centre, applied to
|
|
29
34
|
``state.window_level_target``; Shift targets the other image for that
|
|
@@ -49,6 +54,7 @@ from matplotlib.artist import Artist
|
|
|
49
54
|
from matplotlib.axes import Axes
|
|
50
55
|
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
|
|
51
56
|
from matplotlib.figure import Figure
|
|
57
|
+
from matplotlib.layout_engine import ConstrainedLayoutEngine
|
|
52
58
|
from matplotlib.patches import Rectangle
|
|
53
59
|
|
|
54
60
|
from .event_controllers.viewer_events import ViewerEventHandler
|
|
@@ -81,6 +87,7 @@ from .rendering.image_layer import ImageLayer
|
|
|
81
87
|
from .rendering.isodose import IsoDoseOverlay
|
|
82
88
|
from .rendering.layout import LayoutManager
|
|
83
89
|
from .rendering.render import clim_to_window_level
|
|
90
|
+
from .rendering.view_axes import ImageViewAxes
|
|
84
91
|
from .state.viewer_state import SliceViewerState
|
|
85
92
|
|
|
86
93
|
logger = logging.getLogger(__name__)
|
|
@@ -110,13 +117,30 @@ class DicomViewer(ttk.Frame):
|
|
|
110
117
|
# must exceed the scroll debounce so a full render never lands mid-scroll
|
|
111
118
|
_CACHE_REBUILD_IDLE_MS: int = 150
|
|
112
119
|
|
|
120
|
+
# Constrained-layout padding (inches / figure fraction). Matplotlib's
|
|
121
|
+
# defaults leave a visible frame around and between the views
|
|
122
|
+
_LAYOUT_PAD_INCHES: float = 1 / 72
|
|
123
|
+
_LAYOUT_SPACE: float = 0.0
|
|
124
|
+
|
|
113
125
|
def __init__(
|
|
114
126
|
self,
|
|
115
127
|
parent: tk.Widget,
|
|
116
128
|
state: SliceViewerState | None = None,
|
|
117
129
|
fig_kwargs: dict | None = None,
|
|
130
|
+
*,
|
|
131
|
+
invert_scroll: bool = False,
|
|
118
132
|
) -> None:
|
|
133
|
+
"""Build the widget.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
parent: Tk parent widget.
|
|
137
|
+
state: Shared state; a private one is created when ``None``.
|
|
138
|
+
fig_kwargs: Extra ``Figure`` keyword arguments.
|
|
139
|
+
invert_scroll: Reverse the mouse-wheel direction for slice
|
|
140
|
+
scrolling (see :attr:`scroll_inverted`).
|
|
141
|
+
"""
|
|
119
142
|
super().__init__(parent)
|
|
143
|
+
self._invert_scroll_initial = invert_scroll
|
|
120
144
|
self.rowconfigure(0, weight=1)
|
|
121
145
|
self.columnconfigure(0, weight=1)
|
|
122
146
|
|
|
@@ -139,13 +163,21 @@ class DicomViewer(ttk.Frame):
|
|
|
139
163
|
# ------------------------------------------------------------------
|
|
140
164
|
def _build_widgets(self, fig_kwargs: dict | None) -> None:
|
|
141
165
|
"""Create the figure, canvas, toolbar and blend slider."""
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
kw.update(fig_kwargs
|
|
166
|
+
fig_kwargs = fig_kwargs or {}
|
|
167
|
+
kw: dict = {"figsize": (10, 5), "facecolor": (0.02, 0.02, 0.02)}
|
|
168
|
+
# Matplotlib rejects "layout" together with the legacy layout flags
|
|
169
|
+
if not {"layout", "constrained_layout", "tight_layout"} & fig_kwargs.keys():
|
|
170
|
+
kw["layout"] = "constrained"
|
|
171
|
+
kw.update(fig_kwargs)
|
|
148
172
|
self.fig = Figure(**kw)
|
|
173
|
+
engine = self.fig.get_layout_engine()
|
|
174
|
+
if isinstance(engine, ConstrainedLayoutEngine):
|
|
175
|
+
engine.set(
|
|
176
|
+
w_pad=self._LAYOUT_PAD_INCHES,
|
|
177
|
+
h_pad=self._LAYOUT_PAD_INCHES,
|
|
178
|
+
wspace=self._LAYOUT_SPACE,
|
|
179
|
+
hspace=self._LAYOUT_SPACE,
|
|
180
|
+
)
|
|
149
181
|
self.canvas = FigureCanvasTkAgg(self.fig, master=self)
|
|
150
182
|
self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
|
|
151
183
|
self.toolbar = NavigationToolbar2Tk(self.canvas, self)
|
|
@@ -223,7 +255,9 @@ class DicomViewer(ttk.Frame):
|
|
|
223
255
|
# Last rendered slice per axis (several viewers may share one state)
|
|
224
256
|
self._last_rendered_index: dict[str, int] = dict.fromkeys(AXES, -1)
|
|
225
257
|
|
|
226
|
-
self.event_handler = ViewerEventHandler(
|
|
258
|
+
self.event_handler = ViewerEventHandler(
|
|
259
|
+
self.viewer_state, self, invert_scroll=self._invert_scroll_initial
|
|
260
|
+
)
|
|
227
261
|
|
|
228
262
|
def _bind_events(self) -> None:
|
|
229
263
|
"""Connect canvas events and subscribe to the state."""
|
|
@@ -833,6 +867,49 @@ class DicomViewer(ttk.Frame):
|
|
|
833
867
|
self.drawing_manager.add_request(axis)
|
|
834
868
|
self.drawing_manager.flush()
|
|
835
869
|
|
|
870
|
+
@property
|
|
871
|
+
def scroll_inverted(self) -> bool:
|
|
872
|
+
"""Whether the mouse wheel scrolls slices in the reverse direction.
|
|
873
|
+
|
|
874
|
+
Affects slice scrolling only; the brush size and the Ctrl + wheel
|
|
875
|
+
zoom keep the natural direction.
|
|
876
|
+
"""
|
|
877
|
+
return self.event_handler.invert_scroll
|
|
878
|
+
|
|
879
|
+
@scroll_inverted.setter
|
|
880
|
+
def scroll_inverted(self, inverted: bool) -> None:
|
|
881
|
+
self.event_handler.invert_scroll = bool(inverted)
|
|
882
|
+
|
|
883
|
+
def get_zoom(self, axis: str) -> float:
|
|
884
|
+
"""Return *axis*' zoom factor (1.0 = the whole slice fitted to the view).
|
|
885
|
+
|
|
886
|
+
Returns 1.0 for a view the current layout does not build.
|
|
887
|
+
"""
|
|
888
|
+
ax = self.axs.get(axis)
|
|
889
|
+
return ax.zoom_factor() if isinstance(ax, ImageViewAxes) else 1.0
|
|
890
|
+
|
|
891
|
+
def set_zoom(
|
|
892
|
+
self, axis: str, zoom: float, anchor: tuple[float, float] | None = None
|
|
893
|
+
) -> None:
|
|
894
|
+
"""Zoom *axis* to *zoom*, independently of the other views.
|
|
895
|
+
|
|
896
|
+
Args:
|
|
897
|
+
axis: View to zoom (ignored if the current layout lacks it).
|
|
898
|
+
zoom: Factor, clipped to ``[MIN_ZOOM, MAX_ZOOM]`` of
|
|
899
|
+
:mod:`~tk_rt_viewer.rendering.view_axes`.
|
|
900
|
+
anchor: Data point kept at its screen position; ``None`` zooms
|
|
901
|
+
about the view centre.
|
|
902
|
+
"""
|
|
903
|
+
ax = self.axs.get(axis)
|
|
904
|
+
if isinstance(ax, ImageViewAxes):
|
|
905
|
+
ax.zoom_to(zoom, anchor)
|
|
906
|
+
self.canvas.draw_idle()
|
|
907
|
+
|
|
908
|
+
def reset_zoom(self, axis: str | None = None) -> None:
|
|
909
|
+
"""Fit the whole slice into *axis*, or into every view when ``None``."""
|
|
910
|
+
for name in [axis] if axis is not None else list(self.axs):
|
|
911
|
+
self.set_zoom(name, 1.0)
|
|
912
|
+
|
|
836
913
|
def get_slice(self, view: str) -> np.ndarray:
|
|
837
914
|
"""Return the current 2-D slice for *view* as a NumPy array."""
|
|
838
915
|
if self.viewer_state.primary_image is None:
|
|
@@ -15,10 +15,12 @@ matplotlib.use("Agg")
|
|
|
15
15
|
import numpy as np
|
|
16
16
|
import pytest
|
|
17
17
|
import SimpleITK as sitk
|
|
18
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
18
19
|
from matplotlib.figure import Figure
|
|
19
20
|
|
|
20
21
|
from tk_rt_viewer.event_controllers.viewer_events import ViewerEventHandler
|
|
21
22
|
from tk_rt_viewer.protocols import ViewerHost
|
|
23
|
+
from tk_rt_viewer.rendering.view_axes import ImageViewAxes
|
|
22
24
|
from tk_rt_viewer.state.viewer_state import SliceViewerState
|
|
23
25
|
|
|
24
26
|
|
|
@@ -287,6 +289,135 @@ class TestScrollDebounce:
|
|
|
287
289
|
assert dict(state.indices) == before
|
|
288
290
|
|
|
289
291
|
|
|
292
|
+
class TestScrollInversion:
|
|
293
|
+
def test_inverted_scroll_moves_the_other_way(self) -> None:
|
|
294
|
+
ax = Figure().add_subplot(111)
|
|
295
|
+
state = loaded_state()
|
|
296
|
+
state.set_index("axial", 4)
|
|
297
|
+
handler, viewer = handler_for(state, FakeViewer({"axial": ax}))
|
|
298
|
+
handler.invert_scroll = True
|
|
299
|
+
handler.on_enter_axes(Event(inaxes=ax))
|
|
300
|
+
handler.on_scroll(Event(step=1))
|
|
301
|
+
handler.on_scroll(Event(step=1))
|
|
302
|
+
viewer.run_pending()
|
|
303
|
+
assert state.indices["axial"] == 2
|
|
304
|
+
|
|
305
|
+
def test_inversion_does_not_touch_the_keyboard(self) -> None:
|
|
306
|
+
ax = Figure().add_subplot(111)
|
|
307
|
+
state = loaded_state()
|
|
308
|
+
state.set_index("axial", 4)
|
|
309
|
+
handler, _viewer = handler_for(state, FakeViewer({"axial": ax}))
|
|
310
|
+
handler.invert_scroll = True
|
|
311
|
+
handler.on_enter_axes(Event(inaxes=ax))
|
|
312
|
+
handler.on_key_press(Event(key="up"))
|
|
313
|
+
assert state.indices["axial"] == 5
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class ScrollEvent(Event):
|
|
317
|
+
"""Event carrying Matplotlib's ``modifiers`` set."""
|
|
318
|
+
|
|
319
|
+
def __init__(self, *args, modifiers=frozenset(), **kwargs) -> None:
|
|
320
|
+
super().__init__(*args, **kwargs)
|
|
321
|
+
self.modifiers = frozenset(modifiers)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def zoomable_axes(state: SliceViewerState, axis: str = "axial") -> ImageViewAxes:
|
|
325
|
+
"""An Agg-backed view set up as ImageLayer does for a new slice."""
|
|
326
|
+
fig = Figure(figsize=(6, 3))
|
|
327
|
+
FigureCanvasAgg(fig)
|
|
328
|
+
ax = fig.add_subplot(111, axes_class=ImageViewAxes)
|
|
329
|
+
x0, x1, y0, y1 = state.get_extent(axis)
|
|
330
|
+
ax.set_xlim(x0, x1)
|
|
331
|
+
ax.set_ylim(y1, y0) if axis == "axial" else ax.set_ylim(y0, y1)
|
|
332
|
+
ax.set_aspect("equal", adjustable="box")
|
|
333
|
+
ax.set_image_extent((x0, x1, y0, y1))
|
|
334
|
+
ax.apply_aspect()
|
|
335
|
+
return ax
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def x_fraction(ax, x: float) -> float:
|
|
339
|
+
"""Horizontal screen position of data x within the view (0..1)."""
|
|
340
|
+
left, right = ax.get_xlim()
|
|
341
|
+
return (x - left) / (right - left)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class TestZoom:
|
|
345
|
+
def _setup(self):
|
|
346
|
+
state = loaded_state((8, 16, 32))
|
|
347
|
+
axial = zoomable_axes(state, "axial")
|
|
348
|
+
coronal = zoomable_axes(state, "coronal")
|
|
349
|
+
handler, viewer = handler_for(
|
|
350
|
+
state, FakeViewer({"axial": axial, "coronal": coronal})
|
|
351
|
+
)
|
|
352
|
+
handler.on_enter_axes(Event(inaxes=axial))
|
|
353
|
+
return state, handler, axial, coronal
|
|
354
|
+
|
|
355
|
+
def test_starts_fitted(self) -> None:
|
|
356
|
+
_state, _handler, axial, _coronal = self._setup()
|
|
357
|
+
assert axial.zoom_factor() == pytest.approx(1.0)
|
|
358
|
+
|
|
359
|
+
def test_ctrl_wheel_zooms_about_the_pointer(self) -> None:
|
|
360
|
+
state, handler, axial, _coronal = self._setup()
|
|
361
|
+
before = dict(state.indices)
|
|
362
|
+
anchor = (10.0, 5.0)
|
|
363
|
+
frac = x_fraction(axial, anchor[0])
|
|
364
|
+
handler.on_scroll(
|
|
365
|
+
ScrollEvent(step=1, xdata=anchor[0], ydata=anchor[1], modifiers={"ctrl"})
|
|
366
|
+
)
|
|
367
|
+
axial.apply_aspect()
|
|
368
|
+
assert axial.zoom_factor() == pytest.approx(1.25)
|
|
369
|
+
assert x_fraction(axial, anchor[0]) == pytest.approx(frac)
|
|
370
|
+
assert dict(state.indices) == before # no slice scroll
|
|
371
|
+
assert axial.yaxis_inverted() # radiological orientation kept
|
|
372
|
+
|
|
373
|
+
def test_views_zoom_independently(self) -> None:
|
|
374
|
+
_state, _handler, axial, coronal = self._setup()
|
|
375
|
+
coronal_before = coronal.get_xlim()
|
|
376
|
+
axial.zoom_to(3.0)
|
|
377
|
+
assert axial.zoom_factor() == pytest.approx(3.0)
|
|
378
|
+
assert coronal.get_xlim() == coronal_before
|
|
379
|
+
|
|
380
|
+
def test_zoom_is_clamped_and_stays_on_the_image(self) -> None:
|
|
381
|
+
state, _handler, axial, _coronal = self._setup()
|
|
382
|
+
axial.zoom_to(100.0, anchor=(-50.0, -50.0))
|
|
383
|
+
assert axial.zoom_factor() == pytest.approx(20.0)
|
|
384
|
+
x0, _x1, _y0, _y1 = state.get_extent("axial")
|
|
385
|
+
assert min(axial.get_xlim()) >= x0 - 1e-9
|
|
386
|
+
axial.zoom_to(0.1)
|
|
387
|
+
assert axial.zoom_factor() == pytest.approx(1.0)
|
|
388
|
+
|
|
389
|
+
def test_ctrl_wheel_zooms_even_with_the_brush_active(self) -> None:
|
|
390
|
+
state, handler, axial, _coronal = self._setup()
|
|
391
|
+
state.set_brush_tool_active(True)
|
|
392
|
+
handler.on_scroll(ScrollEvent(step=1, xdata=8.0, ydata=4.0, modifiers={"ctrl"}))
|
|
393
|
+
assert axial.zoom_factor() == pytest.approx(1.25)
|
|
394
|
+
|
|
395
|
+
def test_a_resize_keeps_the_zoom_and_the_centre(self) -> None:
|
|
396
|
+
_state, _handler, axial, _coronal = self._setup()
|
|
397
|
+
axial.zoom_to(2.0, anchor=(20.0, 10.0))
|
|
398
|
+
axial.apply_aspect()
|
|
399
|
+
centre = np.mean(axial.get_xlim()), np.mean(axial.get_ylim())
|
|
400
|
+
axial.figure.set_size_inches(3, 6)
|
|
401
|
+
axial.apply_aspect()
|
|
402
|
+
assert axial.zoom_factor() == pytest.approx(2.0)
|
|
403
|
+
assert np.mean(axial.get_xlim()) == pytest.approx(centre[0])
|
|
404
|
+
# The taller box shows more than the slice height: centred on it
|
|
405
|
+
_x0, _x1, y0, y1 = axial._image_extent
|
|
406
|
+
assert np.mean(axial.get_ylim()) == pytest.approx((y0 + y1) / 2)
|
|
407
|
+
# 1:1 data aspect: data units per pixel match on both axes
|
|
408
|
+
width, height = axial.bbox.width, axial.bbox.height
|
|
409
|
+
x_span = abs(np.diff(axial.get_xlim())[0])
|
|
410
|
+
y_span = abs(np.diff(axial.get_ylim())[0])
|
|
411
|
+
assert x_span / width == pytest.approx(y_span / height)
|
|
412
|
+
|
|
413
|
+
def test_drawing_logs_no_aspect_warning(self, caplog) -> None:
|
|
414
|
+
_state, _handler, axial, _coronal = self._setup()
|
|
415
|
+
axial.zoom_to(3.0, anchor=(4.0, 4.0))
|
|
416
|
+
axial.figure.set_size_inches(5, 5)
|
|
417
|
+
axial.figure.canvas.draw()
|
|
418
|
+
assert "aspect" not in caplog.text
|
|
419
|
+
|
|
420
|
+
|
|
290
421
|
class TestKeyboardNavigation:
|
|
291
422
|
@pytest.mark.parametrize(
|
|
292
423
|
"key,expected", [("up", 5), ("down", 3), ("pageup", 7), ("pagedown", 0)]
|
|
@@ -220,8 +220,8 @@ class TestBlitCompositor:
|
|
|
220
220
|
) -> None:
|
|
221
221
|
"""Pins the 2.0.3 fix: on_draw must key off ax.bbox, not just xlim/ylim.
|
|
222
222
|
|
|
223
|
-
With aspect="equal", adjustable="box" (
|
|
224
|
-
|
|
223
|
+
With aspect="equal", adjustable="box" (the base images used that
|
|
224
|
+
before 2.3.0), a figure resize changes ax.bbox but leaves
|
|
225
225
|
get_xlim()/get_ylim() untouched. Comparing limits alone therefore
|
|
226
226
|
missed every resize, leaving the background bitmap the old size and
|
|
227
227
|
position until some unrelated event happened to trigger a full
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/bbox3d_handler.py
RENAMED
|
File without changes
|
{tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/bbox_handler.py
RENAMED
|
File without changes
|
{tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/brush_handler.py
RENAMED
|
File without changes
|
{tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/crosshair_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
|
|
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
|