pmi-viewer 1.0.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.
- pmi_viewer-1.0.0/.gitignore +1 -0
- pmi_viewer-1.0.0/LICENSE.txt +18 -0
- pmi_viewer-1.0.0/PKG-INFO +56 -0
- pmi_viewer-1.0.0/README.md +32 -0
- pmi_viewer-1.0.0/pyproject.toml +65 -0
- pmi_viewer-1.0.0/src/pmi_viewer/__about__.py +4 -0
- pmi_viewer-1.0.0/src/pmi_viewer/__init__.py +5 -0
- pmi_viewer-1.0.0/src/pmi_viewer/viewer.py +296 -0
- pmi_viewer-1.0.0/tests/__init__.py +3 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/.idea
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present Edwin Bennink <H.E.Bennink@umcutrecht.nl>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
15
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
16
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
18
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pmi-viewer
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Project-URL: Documentation, https://github.com/umcu-isi/pmi-viewer#readme
|
|
5
|
+
Project-URL: Issues, https://github.com/umcu-isi/pmi-viewer/issues
|
|
6
|
+
Project-URL: Source, https://github.com/umcu-isi/pmi-viewer
|
|
7
|
+
Author-email: Edwin Bennink <H.E.Bennink@umcutrecht.nl>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE.txt
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
18
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Requires-Dist: matplotlib>=3.11.1
|
|
21
|
+
Requires-Dist: numpy>=2.5.1
|
|
22
|
+
Requires-Dist: pyside6>=6.11.2
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# pmi-viewer
|
|
26
|
+
|
|
27
|
+
[](https://pypi.org/project/pmi-viewer)
|
|
28
|
+
[](https://pypi.org/project/pmi-viewer)
|
|
29
|
+
|
|
30
|
+
-----
|
|
31
|
+
|
|
32
|
+
`pmi-viewer` is a simple medical image viewer for 2D and 3D NumPy arrays. It supports scrolling through images in
|
|
33
|
+
orthogonal directions and adjusting the display range, similar to other medical image viewers.
|
|
34
|
+
|
|
35
|
+
In a notebook environment, the viewer is displayed in a new output cell (ipympl required). In other environments, it
|
|
36
|
+
opens in a Qt window.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```console
|
|
41
|
+
pip install pmi-viewer
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import numpy as np
|
|
48
|
+
from pmi_viewer import view
|
|
49
|
+
|
|
50
|
+
image = np.random.rand(32, 128, 128)
|
|
51
|
+
view(image, orientation='sag', spacing=(0.7, 0.7, 2.4), title="Random values")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
`pmi-viewer` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# pmi-viewer
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/pmi-viewer)
|
|
4
|
+
[](https://pypi.org/project/pmi-viewer)
|
|
5
|
+
|
|
6
|
+
-----
|
|
7
|
+
|
|
8
|
+
`pmi-viewer` is a simple medical image viewer for 2D and 3D NumPy arrays. It supports scrolling through images in
|
|
9
|
+
orthogonal directions and adjusting the display range, similar to other medical image viewers.
|
|
10
|
+
|
|
11
|
+
In a notebook environment, the viewer is displayed in a new output cell (ipympl required). In other environments, it
|
|
12
|
+
opens in a Qt window.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```console
|
|
17
|
+
pip install pmi-viewer
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import numpy as np
|
|
24
|
+
from pmi_viewer import view
|
|
25
|
+
|
|
26
|
+
image = np.random.rand(32, 128, 128)
|
|
27
|
+
view(image, orientation='sag', spacing=(0.7, 0.7, 2.4), title="Random values")
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## License
|
|
31
|
+
|
|
32
|
+
`pmi-viewer` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pmi-viewer"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = ''
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = []
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Edwin Bennink", email = "H.E.Bennink@umcutrecht.nl" },
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 5 - Production/Stable",
|
|
18
|
+
"Programming Language :: Python",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
25
|
+
"Programming Language :: Python :: Implementation :: PyPy",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"numpy>=2.5.1",
|
|
29
|
+
"pyside6>=6.11.2",
|
|
30
|
+
"matplotlib>=3.11.1",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Documentation = "https://github.com/umcu-isi/pmi-viewer#readme"
|
|
35
|
+
Issues = "https://github.com/umcu-isi/pmi-viewer/issues"
|
|
36
|
+
Source = "https://github.com/umcu-isi/pmi-viewer"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.version]
|
|
39
|
+
path = "src/pmi_viewer/__about__.py"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.envs.types]
|
|
42
|
+
extra-dependencies = [
|
|
43
|
+
"mypy>=1.0.0",
|
|
44
|
+
]
|
|
45
|
+
[tool.hatch.envs.types.scripts]
|
|
46
|
+
check = "mypy --install-types --non-interactive {args:src/pmi_viewer tests}"
|
|
47
|
+
|
|
48
|
+
[tool.coverage.run]
|
|
49
|
+
source_pkgs = ["pmi_viewer", "tests"]
|
|
50
|
+
branch = true
|
|
51
|
+
parallel = true
|
|
52
|
+
omit = [
|
|
53
|
+
"src/pmi_viewer/__about__.py",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.coverage.paths]
|
|
57
|
+
pmi_viewer = ["src/pmi_viewer", "*/pmi-viewer/src/pmi_viewer"]
|
|
58
|
+
tests = ["tests", "*/pmi-viewer/tests"]
|
|
59
|
+
|
|
60
|
+
[tool.coverage.report]
|
|
61
|
+
exclude_lines = [
|
|
62
|
+
"no cov",
|
|
63
|
+
"if __name__ == .__main__.:",
|
|
64
|
+
"if TYPE_CHECKING:",
|
|
65
|
+
]
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from collections import deque
|
|
3
|
+
from typing import Optional, Tuple, Sequence, Literal
|
|
4
|
+
from warnings import warn
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from PySide6.QtWidgets import QMainWindow, QApplication
|
|
8
|
+
|
|
9
|
+
# Try to activate interactive plotting for notebooks. This has to be done before importing matplotlib.
|
|
10
|
+
# If ipython is not available (ModuleNotFoundError) or the 'run_line_magic' function does not exist for the
|
|
11
|
+
# interpreter (AttributeError) then we're probably not running from a notebook.
|
|
12
|
+
_is_notebook = False
|
|
13
|
+
try:
|
|
14
|
+
from IPython import get_ipython
|
|
15
|
+
ip = get_ipython()
|
|
16
|
+
ip.run_line_magic("matplotlib", "widget") # Requires ipympl. Raises a RuntimeError if it is not available.
|
|
17
|
+
_is_notebook = True
|
|
18
|
+
except (ModuleNotFoundError, AttributeError):
|
|
19
|
+
pass # Not a notebook.
|
|
20
|
+
except RuntimeError:
|
|
21
|
+
warn("ipympl is required for interactive visualization in notebooks. The notebook server (JupyterLab or similar) "
|
|
22
|
+
"needs to be restarted.")
|
|
23
|
+
|
|
24
|
+
# Switch to the Qt backend if we're not in a notebook. This has to be done before importing pyplot.
|
|
25
|
+
import matplotlib
|
|
26
|
+
if not _is_notebook:
|
|
27
|
+
matplotlib.use('qtagg')
|
|
28
|
+
|
|
29
|
+
import matplotlib.pyplot as plt
|
|
30
|
+
from matplotlib.widgets import Slider
|
|
31
|
+
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ['StackViewer', 'view']
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# This queue keeps references to StackViewer objects, because once the garbage collector cleaned them up,
|
|
38
|
+
# interaction no longer works.
|
|
39
|
+
_viewers = deque(maxlen=32)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# DICOM defines the linear VOI LUT as:
|
|
43
|
+
# y = ((x - (c - 0.5)) / (w-1) + 0.5) * (ymax- ymin) + ymin,
|
|
44
|
+
# where c = window center, w = window width, and (ymin, ymax) is the output range, e.g. (0, 255).
|
|
45
|
+
# This is a bit odd, because if we choose (ymin, ymax) = (0, 1), then:
|
|
46
|
+
# 0 = ((vmin - (c - 0.5)) / (w - 1) + 0.5)
|
|
47
|
+
# 1 = ((vmax - (c - 0.5)) / (w - 1) + 0.5)
|
|
48
|
+
# Which means that:
|
|
49
|
+
# w = vmax - vmin + 1
|
|
50
|
+
# c = 0.5 * (vmin + vmax + 1)
|
|
51
|
+
|
|
52
|
+
def _range_to_window(vmin: float | int, vmax: float | int) -> Tuple[float, float]:
|
|
53
|
+
"""
|
|
54
|
+
Converts a display range (vmin, vmax) into a window level and window width according to the DICOM standard.
|
|
55
|
+
"""
|
|
56
|
+
wl = 0.5 * (vmax + vmin + 1)
|
|
57
|
+
ww = vmax - vmin + 1
|
|
58
|
+
|
|
59
|
+
return wl, ww
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _window_to_range(wl: float | int, ww: float | int) -> Tuple[float, float]:
|
|
63
|
+
"""
|
|
64
|
+
Converts a window level and window width into a display range (vmin, vmax) according to the DICOM standard.
|
|
65
|
+
"""
|
|
66
|
+
vmin = wl - 0.5 * ww
|
|
67
|
+
vmax = wl + 0.5 * ww - 1
|
|
68
|
+
|
|
69
|
+
return vmin, vmax
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Adapted from https://matplotlib.org/3.5.3/gallery/event_handling/image_slices_viewer.html
|
|
73
|
+
class StackViewer:
|
|
74
|
+
"""
|
|
75
|
+
A 3D image stack viewer.
|
|
76
|
+
|
|
77
|
+
:param data: A 2D or 3D Numpy array in LPS orientation and row-major ordering.
|
|
78
|
+
:param ax: A matplotlib Axes.
|
|
79
|
+
:param orientation: 'ax' or 'cor' or 'sag'. Defaults to 'ax'
|
|
80
|
+
:param spacing: An optional list, tuple or Numpy array with the (x, y, z) spacing.
|
|
81
|
+
:param slider_ax: An optional matplotlib Axes for the stack slider.
|
|
82
|
+
"""
|
|
83
|
+
def __init__(self, data: np.ndarray, ax: plt.Axes, orientation: Optional[str] = None, spacing: Optional[Sequence[float] | np.ndarray] = None, slider_ax: Optional[plt.Axes] = None):
|
|
84
|
+
self.ax = ax
|
|
85
|
+
self.fig = ax.figure
|
|
86
|
+
|
|
87
|
+
if orientation and data.ndim < 3:
|
|
88
|
+
orientation = None
|
|
89
|
+
warn("Orientation is ignored for {data.ndim}-dimensional data.")
|
|
90
|
+
self.orientation = orientation or 'ax'
|
|
91
|
+
|
|
92
|
+
if data.ndim == 2:
|
|
93
|
+
self.data = np.expand_dims(data, axis=0) # Convert 2D data to 3D by adding a dimension.
|
|
94
|
+
elif data.ndim == 3:
|
|
95
|
+
self.data = data
|
|
96
|
+
else:
|
|
97
|
+
raise ValueError(f"Cannot handle {data.ndim}-dimensional data.")
|
|
98
|
+
|
|
99
|
+
self.nz, self.ny, self.nx = self.data.shape # Get the number of slices in the 3D stack, and the height and width.
|
|
100
|
+
|
|
101
|
+
if spacing is None:
|
|
102
|
+
spacing = [1, 1, 1]
|
|
103
|
+
|
|
104
|
+
# Set aspect ratio and initial position in the stack (starting at index=1).
|
|
105
|
+
origin: Optional[Literal['upper', 'lower']]
|
|
106
|
+
if self.orientation == 'ax':
|
|
107
|
+
origin = 'upper'
|
|
108
|
+
aspect = spacing[1] / spacing[0]
|
|
109
|
+
self.slices, h, w = self.nz, self.ny, self.nx
|
|
110
|
+
elif self.orientation == 'cor':
|
|
111
|
+
origin = 'lower'
|
|
112
|
+
aspect = spacing[2] / spacing[0]
|
|
113
|
+
self.slices, h, w = self.ny, self.nz, self.nx
|
|
114
|
+
elif self.orientation == 'sag':
|
|
115
|
+
origin = 'lower'
|
|
116
|
+
aspect = spacing[2] / spacing[1]
|
|
117
|
+
self.slices, h, w = self.nx, self.nz, self.ny
|
|
118
|
+
else:
|
|
119
|
+
raise ValueError(f"Invalid orientation: {self.orientation}.")
|
|
120
|
+
self.index = self.slices // 2 + 1
|
|
121
|
+
|
|
122
|
+
# Calculate the initial window level and width based on the min and max data value.
|
|
123
|
+
vmin = float(data.min())
|
|
124
|
+
vmax = float(data.max())
|
|
125
|
+
self.wl, self.ww = _range_to_window(vmin, vmax)
|
|
126
|
+
|
|
127
|
+
# Show the image.
|
|
128
|
+
self.im = self.ax.imshow(self.get_slice(), cmap='gray', origin=origin, aspect=aspect, vmin=vmin, vmax=vmax)
|
|
129
|
+
|
|
130
|
+
# Show the orientation labels.
|
|
131
|
+
self._show_orientation_labels(w, h)
|
|
132
|
+
|
|
133
|
+
# Show the W/L and stack index annotations.
|
|
134
|
+
self.update_annotation()
|
|
135
|
+
|
|
136
|
+
# Add the optional stack slider.
|
|
137
|
+
if slider_ax:
|
|
138
|
+
self.slider = Slider(
|
|
139
|
+
slider_ax,
|
|
140
|
+
"",
|
|
141
|
+
1,
|
|
142
|
+
self.slices,
|
|
143
|
+
valinit=self.index,
|
|
144
|
+
valstep=1,
|
|
145
|
+
orientation="vertical",
|
|
146
|
+
)
|
|
147
|
+
slider_ax.invert_yaxis()
|
|
148
|
+
self.slider.poly.set_visible(False)
|
|
149
|
+
self.slider.on_changed(self.set_index)
|
|
150
|
+
else:
|
|
151
|
+
self.slider = None
|
|
152
|
+
|
|
153
|
+
# Initialize helper variables for the W/L callbacks.
|
|
154
|
+
self._window_init = (self.wl, self.ww)
|
|
155
|
+
self._coord_init = (0, 0)
|
|
156
|
+
|
|
157
|
+
# Connect callback functions.
|
|
158
|
+
self.fig.canvas.mpl_connect('scroll_event', self._on_scroll)
|
|
159
|
+
self.fig.canvas.mpl_connect('button_press_event', self._on_button_press)
|
|
160
|
+
self.fig.canvas.mpl_connect('motion_notify_event', self._on_motion)
|
|
161
|
+
|
|
162
|
+
# If true then scrolling while the mouse is over the canvas will not move the entire webpage.
|
|
163
|
+
self.fig.canvas.capture_scroll = True
|
|
164
|
+
|
|
165
|
+
def _show_orientation_labels(self, w: int, h: int):
|
|
166
|
+
# Show the orientation annotations. Don't make any assumptions on the orientation of the y-axis for 2D images.
|
|
167
|
+
if self.orientation in ('ax', 'cor'):
|
|
168
|
+
self.ax.text(0.05 * w, 0.50 * h, 'R', weight='bold', color='orange', va='center', ha='center')
|
|
169
|
+
self.ax.text(0.95 * w, 0.50 * h, 'L', weight='bold', color='orange', va='center', ha='center')
|
|
170
|
+
elif self.orientation == 'sag':
|
|
171
|
+
self.ax.text(0.05 * w, 0.50 * h, 'A', weight='bold', color='orange', va='center', ha='center')
|
|
172
|
+
self.ax.text(0.95 * w, 0.50 * h, 'P', weight='bold', color='orange', va='center', ha='center')
|
|
173
|
+
if self.slices > 1:
|
|
174
|
+
if self.orientation in ('sag', 'cor'):
|
|
175
|
+
self.ax.text(0.50 * w, 0.05 * h, 'I', weight='bold', color='orange', va='center', ha='center')
|
|
176
|
+
self.ax.text(0.50 * w, 0.95 * h, 'S', weight='bold', color='orange', va='center', ha='center')
|
|
177
|
+
elif self.orientation == 'ax':
|
|
178
|
+
self.ax.text(0.50 * w, 0.05 * h, 'A', weight='bold', color='orange', va='center', ha='center')
|
|
179
|
+
self.ax.text(0.50 * w, 0.95 * h, 'P', weight='bold', color='orange', va='center', ha='center')
|
|
180
|
+
|
|
181
|
+
def get_slice(self):
|
|
182
|
+
if self.orientation == 'ax':
|
|
183
|
+
return self.data[self.index - 1]
|
|
184
|
+
elif self.orientation == 'cor':
|
|
185
|
+
return self.data[:, self.index - 1]
|
|
186
|
+
elif self.orientation == 'sag':
|
|
187
|
+
return self.data[:, :, self.index - 1]
|
|
188
|
+
else:
|
|
189
|
+
raise ValueError(f"Invalid orientation: {self.orientation}.")
|
|
190
|
+
|
|
191
|
+
def update_annotation(self):
|
|
192
|
+
self.ax.set_xlabel(f'WL: {self.wl:.4g} WW: {self.ww:.4g}')
|
|
193
|
+
self.ax.set_ylabel(f'{self.index} of {self.slices}')
|
|
194
|
+
|
|
195
|
+
def set_index(self, index: int | float):
|
|
196
|
+
index = int(np.clip(index, 1, self.slices))
|
|
197
|
+
|
|
198
|
+
if self.slider and self.slider.val != index:
|
|
199
|
+
self.slider.set_val(index)
|
|
200
|
+
|
|
201
|
+
if self.index != index:
|
|
202
|
+
self.index = index
|
|
203
|
+
self.update()
|
|
204
|
+
|
|
205
|
+
def update(self):
|
|
206
|
+
self.im.set_data(self.get_slice())
|
|
207
|
+
self.update_annotation()
|
|
208
|
+
self.fig.canvas.draw_idle()
|
|
209
|
+
|
|
210
|
+
def _on_scroll(self, event: plt.MouseEvent):
|
|
211
|
+
if event.button == 'up':
|
|
212
|
+
self.set_index(self.index - 1)
|
|
213
|
+
else:
|
|
214
|
+
self.set_index(self.index + 1)
|
|
215
|
+
|
|
216
|
+
def _on_button_press(self, event: plt.MouseEvent):
|
|
217
|
+
if event.button == 1:
|
|
218
|
+
self._window_init = (self.wl, self.ww)
|
|
219
|
+
self._coord_init = (event.x, event.y)
|
|
220
|
+
|
|
221
|
+
def _on_motion(self, event: plt.MouseEvent):
|
|
222
|
+
if event.button != 1:
|
|
223
|
+
return # Left button is not pressed.
|
|
224
|
+
if event.inaxes != self.ax:
|
|
225
|
+
return # Not inside the image (but e.g. on the scrollbar).
|
|
226
|
+
if self.fig.canvas.toolbar and self.fig.canvas.toolbar.mode:
|
|
227
|
+
return # A toolbar tool is active (e.g. zoom or drag).
|
|
228
|
+
|
|
229
|
+
dx = event.x - self._coord_init[0]
|
|
230
|
+
dy = event.y - self._coord_init[1]
|
|
231
|
+
|
|
232
|
+
sensitivity = self._window_init[1] / 500
|
|
233
|
+
self.wl = self._window_init[0] + sensitivity * dy
|
|
234
|
+
self.ww = max(self._window_init[1] + sensitivity * dx, 2)
|
|
235
|
+
|
|
236
|
+
vmin, vmax = _window_to_range(self.wl, self.ww)
|
|
237
|
+
self.im.set_clim(vmin=vmin, vmax=vmax)
|
|
238
|
+
self.update()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def view(data: np.ndarray, orientation: Optional[str] = None, spacing:Optional[Sequence[float] | np.ndarray] = None, title:Optional[str] = None):
|
|
242
|
+
"""
|
|
243
|
+
Shows the given 2D or 3D image stack in a scrollable viewer.
|
|
244
|
+
|
|
245
|
+
:param data: A 2D or 3D Numpy array in LPS orientation and row-major ordering.
|
|
246
|
+
:param orientation: 'ax' or 'cor' or 'sag' (3D data only). Defaults to 'ax'.
|
|
247
|
+
:param spacing: An optional list, tuple or Numpy array with the (x, y, z) spacing.
|
|
248
|
+
:param title: An optional figure title.
|
|
249
|
+
"""
|
|
250
|
+
# Create a matplotlib figure and a layout with 2 columns.
|
|
251
|
+
if _is_notebook:
|
|
252
|
+
fig = plt.figure()
|
|
253
|
+
else:
|
|
254
|
+
fig = matplotlib.figure.Figure()
|
|
255
|
+
|
|
256
|
+
gs = fig.add_gridspec(
|
|
257
|
+
1, 2,
|
|
258
|
+
width_ratios=[20, 1],
|
|
259
|
+
wspace=0.05
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Add an Axes for the image.
|
|
263
|
+
ax = fig.add_subplot(gs[0, 0])
|
|
264
|
+
if _is_notebook and title:
|
|
265
|
+
ax.set_title(title)
|
|
266
|
+
|
|
267
|
+
# Add an Axes for the slider.
|
|
268
|
+
if data.ndim > 2:
|
|
269
|
+
slider_ax = fig.add_subplot(gs[0, 1])
|
|
270
|
+
else:
|
|
271
|
+
slider_ax = None
|
|
272
|
+
|
|
273
|
+
# Create the viewer.
|
|
274
|
+
viewer = StackViewer(data, ax, orientation=orientation, spacing=spacing, slider_ax=slider_ax)
|
|
275
|
+
|
|
276
|
+
if _is_notebook:
|
|
277
|
+
# Store the viewer's reference to prevent GC cleanup.
|
|
278
|
+
_viewers.append(viewer)
|
|
279
|
+
|
|
280
|
+
# Show the figure in a new notebook cell.
|
|
281
|
+
plt.show()
|
|
282
|
+
else:
|
|
283
|
+
# Show the figure in a new Qt Window. This should also work from PyCharm with “Show plots in tool window”
|
|
284
|
+
# enabled.
|
|
285
|
+
app = QApplication.instance()
|
|
286
|
+
if app is None:
|
|
287
|
+
app = QApplication(sys.argv)
|
|
288
|
+
window = QMainWindow()
|
|
289
|
+
window.setWindowTitle(title or "Viewer")
|
|
290
|
+
canvas = FigureCanvasQTAgg(fig)
|
|
291
|
+
window.setCentralWidget(canvas)
|
|
292
|
+
window.resize(800, 600)
|
|
293
|
+
window.show()
|
|
294
|
+
window.raise_()
|
|
295
|
+
window.activateWindow()
|
|
296
|
+
app.exec()
|