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.
Files changed (70) hide show
  1. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/CHANGELOG.md +28 -0
  2. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/PKG-INFO +30 -3
  3. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/README.md +29 -2
  4. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/__init__.py +1 -1
  5. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/viewer_events.py +57 -3
  6. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/image_layer.py +7 -0
  7. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/layout.py +15 -7
  8. tk_rt_viewer-2.3.0/src/tk_rt_viewer/rendering/view_axes.py +188 -0
  9. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/viewer.py +85 -8
  10. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_event_controllers.py +131 -0
  11. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_rendering.py +2 -2
  12. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/.gitattributes +0 -0
  13. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/.github/workflows/ci.yml +0 -0
  14. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/.gitignore +0 -0
  15. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/LICENSE +0 -0
  16. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/pyproject.toml +0 -0
  17. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/__init__.py +0 -0
  18. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +0 -0
  19. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/bbox_handler.py +0 -0
  20. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/brush_handler.py +0 -0
  21. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/crosshair_handler.py +0 -0
  22. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/event_controllers/rect_drag.py +0 -0
  23. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/events.py +0 -0
  24. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/geometry.py +0 -0
  25. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/io.py +0 -0
  26. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/isodose_levels.py +0 -0
  27. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/protocols.py +0 -0
  28. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/py.typed +0 -0
  29. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/reg_io.py +0 -0
  30. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/registration/__init__.py +0 -0
  31. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/registration/deformable.py +0 -0
  32. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/registration/errors.py +0 -0
  33. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/registration/params.py +0 -0
  34. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/registration/rigid.py +0 -0
  35. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/registration/session.py +0 -0
  36. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/registration/template.py +0 -0
  37. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/__init__.py +0 -0
  38. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/blit_compositor.py +0 -0
  39. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/contour_overlay.py +0 -0
  40. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/drawing_manager.py +0 -0
  41. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/dvh.py +0 -0
  42. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/isodose.py +0 -0
  43. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rendering/render.py +0 -0
  44. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/roi_operations.py +0 -0
  45. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/rtstruct_io.py +0 -0
  46. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/__init__.py +0 -0
  47. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/dose_manager.py +0 -0
  48. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/phase_manager.py +0 -0
  49. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/roi_editor.py +0 -0
  50. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/roi_manager.py +0 -0
  51. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/secondary_manager.py +0 -0
  52. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/structure_set.py +0 -0
  53. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/viewer_cache.py +0 -0
  54. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/state/viewer_state.py +0 -0
  55. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/src/tk_rt_viewer/window_level.py +0 -0
  56. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_bbox3d.py +0 -0
  57. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_bbox3d_handler.py +0 -0
  58. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_brush_handler.py +0 -0
  59. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_geometry.py +0 -0
  60. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_io.py +0 -0
  61. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_isodose_levels.py +0 -0
  62. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_performance_opt.py +0 -0
  63. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_reg_io.py +0 -0
  64. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_registration.py +0 -0
  65. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_render.py +0 -0
  66. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_roi_operations.py +0 -0
  67. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_rtstruct_io.py +0 -0
  68. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_state_extensions.py +0 -0
  69. {tk_rt_viewer-2.2.1 → tk_rt_viewer-2.3.0}/tests/test_state_managers.py +0 -0
  70. {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.2.1
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
- │ └── layout.py # LayoutManager (single / mpr / mpr_wide layouts)
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
- │ └── layout.py # LayoutManager (single / mpr / mpr_wide layouts)
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
@@ -104,7 +104,7 @@ __all__ = [
104
104
  "StructureSet",
105
105
  "to_gy_pairs",
106
106
  ]
107
- __version__ = "2.2.1"
107
+ __version__ = "2.3.0"
108
108
 
109
109
 
110
110
  def __getattr__(name: str) -> Any:
@@ -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__(self, state: SliceViewerState, viewer: ViewerHost) -> None:
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
- """Resize the brush immediately, or accumulate a debounced slice scroll."""
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._scroll_accum += int(np.sign(event.step))
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._fig.add_subplot(111)}
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._fig.add_subplot(gs[:, 0]),
56
- "coronal": self._fig.add_subplot(gs[0, 1]),
57
- "sagittal": self._fig.add_subplot(gs[1, 1]),
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._fig.add_subplot(gs[0, 0]),
64
- "coronal": self._fig.add_subplot(gs[1, 0]),
65
- "sagittal": self._fig.add_subplot(gs[1, 1]),
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
- kw: dict = {
143
- "figsize": (10, 5),
144
- "facecolor": (0.02, 0.02, 0.02),
145
- "constrained_layout": True,
146
- }
147
- kw.update(fig_kwargs or {})
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(self.viewer_state, self)
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" (every base image in this
224
- package uses that), a figure resize changes ax.bbox but leaves
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