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.
@@ -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
+ [![PyPI - Version](https://img.shields.io/pypi/v/pmi-viewer.svg)](https://pypi.org/project/pmi-viewer)
28
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pmi-viewer.svg)](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
+ [![PyPI - Version](https://img.shields.io/pypi/v/pmi-viewer.svg)](https://pypi.org/project/pmi-viewer)
4
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pmi-viewer.svg)](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,4 @@
1
+ # SPDX-FileCopyrightText: 2026-present Edwin Bennink <H.E.Bennink@umcutrecht.nl>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "1.0.0"
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: 2026-present Edwin Bennink <H.E.Bennink@umcutrecht.nl>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from .viewer import view
@@ -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()
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026-present Edwin Bennink <H.E.Bennink@umcutrecht.nl>
2
+ #
3
+ # SPDX-License-Identifier: MIT