├── .gitignore ├── LICENSE ├── PyQtImageViewer ├── QtImageStackViewer.py ├── QtImageViewer.py └── __init__.py ├── README.md └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled source # 2 | ################### 3 | *.com 4 | *.class 5 | *.dll 6 | *.exe 7 | *.o 8 | *.so 9 | *.pyo 10 | *.pyc 11 | *.pyd 12 | 13 | # Packages # 14 | ############ 15 | # it's better to unpack these files and commit the raw source 16 | # git has its own built in compression methods 17 | *.7z 18 | *.dmg 19 | *.gz 20 | *.iso 21 | *.jar 22 | *.rar 23 | *.tar 24 | *.zip 25 | 26 | # Logs and databases # 27 | ###################### 28 | *.log 29 | *.sql 30 | *.sqlite 31 | 32 | # OS generated files # 33 | ###################### 34 | .DS_Store 35 | .DS_Store? 36 | ._* 37 | .Spotlight-V100 38 | .Trashes 39 | ehthumbs.db 40 | Thumbs.db 41 | *~ 42 | .idea/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Marcel Goldschen-Ohm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /PyQtImageViewer/QtImageStackViewer.py: -------------------------------------------------------------------------------- 1 | """ QtImageStackViewer.py: PyQt image stack viewer similar to that in ImageJ. 2 | 3 | """ 4 | 5 | import numpy as np 6 | from PIL import Image 7 | import os.path 8 | 9 | try: 10 | from PyQt6.QtCore import Qt, QSize 11 | from PyQt6.QtGui import QImage, QPixmap 12 | from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QSizePolicy, QScrollBar, QToolBar, QLabel, QFileDialog, QStyle 13 | except ImportError: 14 | try: 15 | from PyQt5.QtCore import Qt, QSize 16 | from PyQt5.QtGui import QImage, QPixmap 17 | from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QSizePolicy, QScrollBar, QToolBar, QLabel, QFileDialog, QStyle 18 | except ImportError: 19 | raise ImportError("Requires PyQt (version 5 or 6)") 20 | 21 | from QtImageViewer import QtImageViewer 22 | 23 | 24 | __author__ = "Marcel Goldschen-Ohm " 25 | __version__ = '0.1.0' 26 | 27 | 28 | def isDarkColor(qcolor): 29 | darkness = 1 - (0.299 * qcolor.red() + 0.587 * qcolor.green() + 0.114 * qcolor.blue()) / 255 30 | return darkness >= 0.5 31 | 32 | 33 | def invertIconColors(qicon, w, h): 34 | qimage = QImage(qicon.pixmap(w, h)) 35 | qimage.invertPixels() 36 | pixmap = QPixmap.fromImage(qimage) 37 | qicon.addPixmap(pixmap) 38 | 39 | 40 | class QtImageStackViewer(QWidget): 41 | """ QtImageStackViewer.py: PyQt image stack viewer similar to that in ImageJ. 42 | 43 | Uses a QtImageViewer with frame/channel sliders and a titlebar indicating current frame and mouse position 44 | similar to that in ImageJ. 45 | 46 | Display a multi-page image stack with a slider to traverse the frames in the stack. 47 | Can also optionally split color channels with a second slider. 48 | 49 | Image stack data can be loaded either directly as a NumPy 3D array [rows, columns, frames] or from file using PIL. 50 | 51 | If reading multi-page image data from file using PIL, only the currently displayed frame will be kept in memory, 52 | so loading and scrolling through even huge image stacks is very fast. 53 | """ 54 | 55 | def __init__(self, image=None): 56 | QWidget.__init__(self) 57 | 58 | # Image data: NumPy array - OR - PIL image file object = PIL.Image.open(...) 59 | if type(image) is str: 60 | self._image = Image.open(image) 61 | else: 62 | self._image = image 63 | 64 | # Store data for current frame 65 | self._currentFrame = None 66 | 67 | # Display multiple channels individually in grayscale (choose selected channel with scrollbar). 68 | self._separateChannels = False 69 | 70 | # QtImageViewer 71 | self.imageViewer = QtImageViewer() 72 | self.imageViewer.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)) 73 | 74 | # Scrollbars for frames/channels/etc. 75 | self._scrollbars = [] 76 | 77 | # Mouse wheel behavior 78 | self._wheelScrollsFrame = True 79 | self._wheelZoomFactor = 1.25 80 | 81 | self.label = QLabel() 82 | font = self.label.font() 83 | font.setPointSize(10) 84 | self.label.setFont(font) 85 | self.label.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)) 86 | 87 | self.toolbar = QToolBar() 88 | self.toolbar.setOrientation(Qt.Orientation.Horizontal) 89 | self.toolbar.setFloatable(False) 90 | self.toolbar.setMovable(False) 91 | self.toolbar.setIconSize(QSize(10, 10)) 92 | self.toolbar.setContentsMargins(0, 0, 0, 0) 93 | self.toolbar.setStyleSheet("QToolBar { spacing: 2px; }") 94 | self.toolbar.addWidget(self.label) 95 | 96 | bgColor = self.palette().color(QWidget.backgroundRole(self)) 97 | isDarkMode = isDarkColor(bgColor) 98 | 99 | qpixmapi = getattr(QStyle.StandardPixmap, "SP_MediaPlay") 100 | qicon = self.style().standardIcon(qpixmapi) 101 | if isDarkMode: 102 | invertIconColors(qicon, 10, 10) 103 | self.playAction = self.toolbar.addAction(qicon, "", self.play) 104 | 105 | qpixmapi = getattr(QStyle.StandardPixmap, "SP_MediaPause") 106 | qicon = self.style().standardIcon(qpixmapi) 107 | if isDarkMode: 108 | invertIconColors(qicon, 10, 10) 109 | self.pauseAction = self.toolbar.addAction(qicon, "", self.pause) 110 | 111 | vbox = QVBoxLayout(self) 112 | vbox.addWidget(self.toolbar) 113 | vbox.addWidget(self.imageViewer) 114 | vbox.setContentsMargins(5, 5, 5, 5) 115 | vbox.setSpacing(2) 116 | 117 | # Track mouse position on image. 118 | self.imageViewer.setMouseTracking(True) 119 | self.imageViewer.mousePositionOnImageChanged.connect(self.updateLabel) 120 | 121 | # For play/pause actions. 122 | self._isPlaying = False 123 | 124 | self.updateViewer() 125 | 126 | self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 127 | 128 | def image(self): 129 | return self._image 130 | 131 | def setImage(self, im): 132 | self._image = im 133 | self.updateViewer() 134 | 135 | def currentFrame(self): 136 | return self._currentFrame 137 | 138 | def open(self, filepath=None): 139 | if filepath is None: 140 | filepath, dummy = QFileDialog.getOpenFileName(self, "Select image file.") 141 | if len(filepath) and os.path.isfile(filepath): 142 | self.setImage(Image.open(filepath)) 143 | 144 | def loadData(self): 145 | if type(self._image) is np.ndarray: 146 | return self._image 147 | else: 148 | # PIL Image file object = PIL.Image.open(...) 149 | channels = self._image.getbands() 150 | n_channels = len(channels) 151 | n_frames = self._image.n_frames 152 | if n_frames == 1: 153 | return np.array(self._image) 154 | if n_frames > 1: 155 | self._image.seek(0) 156 | firstFrame = np.array(self._image) 157 | if n_channels == 1: 158 | data = np.zeros((self._image.height, self._image.width, n_frames), 159 | dtype=firstFrame.dtype) 160 | else: 161 | data = np.zeros((self._image.height, self._image.width, n_channels, n_frames), 162 | dtype=firstFrame.dtype) 163 | data[:,:,:n_channels] = firstFrame 164 | for i in range(1, n_frames): 165 | self._image.seek(i) 166 | if n_channels == 1: 167 | data[:,:,i] = np.array(self._image) 168 | else: 169 | data[:,:,i*n_channels:(i+1)*n_channels] = np.array(self._image) 170 | return data 171 | 172 | def separateChannels(self): 173 | return self._separateChannels 174 | 175 | def setSeparateChannels(self, tf): 176 | self._separateChannels = tf 177 | self.updateViewer() 178 | 179 | def currentIndexes(self): 180 | return [scrollbar.value() for scrollbar in self._scrollbars] 181 | 182 | def setCurrentIndexes(self, indexes): 183 | for i, index in enumerate(indexes): 184 | self._scrollbars[i].setValue(index) 185 | self.updateFrame() 186 | 187 | def wheelScrollsFrame(self): 188 | return self._wheelScrollsFrame 189 | 190 | def setWheelScrollsFrame(self, tf): 191 | self._wheelScrollsFrame = tf 192 | if tf: 193 | self.imageViewer.wheelZoomFactor = 1 194 | else: 195 | self.imageViewer.wheelZoomFactor = self._wheelZoomFactor 196 | 197 | def wheelZoomFactor(self): 198 | return self._wheelZoomFactor 199 | 200 | def setWheelZoomFactor(self, zoomFactor): 201 | self._wheelZoomFactor = zoomFactor 202 | if not self._wheelScrollsFrame: 203 | self.imageViewer.wheelZoomFactor = zoomFactor 204 | 205 | def updateViewer(self): 206 | if self._image is None: 207 | self.imageViewer.clearImage() 208 | del self._scrollbars[:] 209 | return 210 | 211 | if type(self._image) is np.ndarray: 212 | # Treat numpy.ndarray as grayscale intensity image. 213 | # Add scrollbars for every dimension after the first two. 214 | n_scrollbars = max(0, self._image.ndim - 2) 215 | if len(self._scrollbars) > n_scrollbars: 216 | for sb in self._scrollbars[n_scrollbars:]: 217 | sb.deleteLater() 218 | del self._scrollbars[n_scrollbars:] 219 | while len(self._scrollbars) < n_scrollbars: 220 | scrollbar = QScrollBar(Qt.Orientation.Horizontal) 221 | scrollbar.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)) 222 | scrollbar.valueChanged.connect(self.updateFrame) 223 | self.layout().addWidget(scrollbar) 224 | self._scrollbars.append(scrollbar) 225 | for i in range(n_scrollbars): 226 | self._scrollbars[i].setRange(0, self._image.shape[i+2]-1) 227 | self._scrollbars[i].setValue(0) 228 | else: 229 | # PIL Image file object = PIL.Image.open(...) 230 | channels = self._image.getbands() 231 | n_channels = len(channels) 232 | n_frames = self._image.n_frames 233 | n_scrollbars = 0 234 | if n_channels > 1 and self._separateChannels: 235 | n_scrollbars += 1 236 | if n_frames > 1: 237 | n_scrollbars += 1 238 | if len(self._scrollbars) > n_scrollbars: 239 | for sb in self._scrollbars[n_scrollbars:]: 240 | sb.deleteLater() 241 | del self._scrollbars[n_scrollbars:] 242 | while len(self._scrollbars) < n_scrollbars: 243 | scrollbar = QScrollBar(Qt.Orientation.Horizontal) 244 | scrollbar.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)) 245 | scrollbar.valueChanged.connect(self.updateFrame) 246 | self.layout().addWidget(scrollbar) 247 | self._scrollbars.append(scrollbar) 248 | i = 0 249 | if n_channels > 1 and self._separateChannels: 250 | self._scrollbars[i].setRange(0, n_channels - 1) 251 | self._scrollbars[i].setValue(0) 252 | i += 1 253 | if n_frames > 1: 254 | self._scrollbars[i].setRange(0, n_frames - 1) 255 | self._scrollbars[i].setValue(0) 256 | 257 | # mouse wheel scroll frames vs. zoom 258 | if len(self._scrollbars) > 0 and self._wheelScrollsFrame: 259 | # wheel scrolls frame (i.e., last dimension) 260 | self.imageViewer.wheelZoomFactor = None 261 | else: 262 | # wheel zoom 263 | self.imageViewer.wheelZoomFactor = self._wheelZoomFactor 264 | 265 | self.updateFrame() 266 | 267 | def updateFrame(self): 268 | if self._image is None: 269 | return 270 | 271 | if type(self._image) is np.ndarray: 272 | rows = np.arange(self._image.shape[0]) 273 | cols = np.arange(self._image.shape[1]) 274 | indexes = [rows, cols] 275 | indexes.extend([[i] for i in self.currentIndexes()]) 276 | self._currentFrame = self._image[np.ix_(*indexes)] 277 | self.imageViewer.setImage(self._currentFrame.copy()[:,:,0]) 278 | else: 279 | # PIL Image file object = PIL.Image.open(...) 280 | channels = self._image.getbands() 281 | n_channels = len(channels) 282 | n_frames = self._image.n_frames 283 | indexes = self.currentIndexes() 284 | if n_frames > 1: 285 | frameIndex = indexes[-1] 286 | self._image.seek(frameIndex) 287 | if n_channels > 1 and self._separateChannels: 288 | channelIndex = indexes[0] 289 | self._currentFrame = np.array(self._image)[:,:,channelIndex] 290 | self.imageViewer.setImage(self._currentFrame.copy()) 291 | else: 292 | try: 293 | self._currentFrame = QImage(self._image.toqimage()) 294 | self.imageViewer.setImage(self._currentFrame) 295 | except ValueError: 296 | self._currentFrame = np.array(self._image) 297 | self.imageViewer.setImage(self._currentFrame.copy()) 298 | 299 | self.updateLabel() 300 | 301 | def updateLabel(self, imagePixelPosition=None): 302 | if self._image is None: 303 | return 304 | 305 | label = "" 306 | for sb in self._scrollbars: 307 | label += str(sb.value() + 1) + "/" + str(sb.maximum() + 1) + "; " 308 | if type(self._image) is np.ndarray: 309 | width = self._image.shape[1] 310 | height = self._image.shape[0] 311 | else: 312 | # PIL Image file object = PIL.Image.open(...) 313 | width = self._image.width 314 | height = self._image.height 315 | label += str(width) + "x" + str(height) 316 | if imagePixelPosition is not None: 317 | x = imagePixelPosition.x() 318 | y = imagePixelPosition.y() 319 | if 0 <= x < width and 0 <= y < height: 320 | label += "; x=" + str(x) + ", y=" + str(y) 321 | if self._currentFrame is not None: 322 | if type(self._currentFrame) is np.ndarray: 323 | value = self._currentFrame[y, x] 324 | else: 325 | # PIL Image file object = PIL.Image.open(...) 326 | value = self._image.getpixel((x, y)) 327 | label += ", value=" + str(value) 328 | if type(self._image) is not np.ndarray: 329 | # PIL Image file object = PIL.Image.open(...) 330 | try: 331 | path, filename = os.path.split(self._image.filename) 332 | if len(filename) > 0: 333 | label += "; " + filename 334 | except: 335 | pass 336 | self.label.setText(label) 337 | 338 | def wheelEvent(self, event): 339 | if len(self._scrollbars) == 0: 340 | return 341 | n_frames = self._scrollbars[-1].maximum() + 1 342 | if self._wheelScrollsFrame and n_frames > 1: 343 | i = self._scrollbars[-1].value() 344 | if event.angleDelta().y() < 0: 345 | # next frame 346 | if i < n_frames - 1: 347 | self._scrollbars[-1].setValue(i + 1) 348 | self.updateFrame() 349 | else: 350 | # prev frame 351 | if i > 0: 352 | self._scrollbars[-1].setValue(i - 1) 353 | self.updateFrame() 354 | return 355 | 356 | QWidget.wheelEvent(self, event) 357 | 358 | def leaveEvent(self, event): 359 | self.updateLabel() 360 | 361 | def play(self): 362 | if len(self._scrollbars) == 0: 363 | return 364 | self._isPlaying = True 365 | first = self._scrollbars[-1].value() 366 | last = self._scrollbars[-1].maximum() 367 | for i in range(first, last + 1): 368 | self._scrollbars[-1].setValue(i) 369 | self.updateFrame() 370 | QApplication.processEvents() 371 | if not self._isPlaying: 372 | break 373 | self._isPlaying = False 374 | 375 | def pause(self): 376 | self._isPlaying = False 377 | 378 | 379 | if __name__ == '__main__': 380 | import sys 381 | try: 382 | from PyQt6.QtWidgets import QApplication 383 | except ImportError: 384 | from PyQt5.QtWidgets import QApplication 385 | 386 | # Create the application. 387 | app = QApplication(sys.argv) 388 | 389 | # Create viewer. 390 | viewer = QtImageStackViewer() 391 | 392 | # Open an image stack from file. 393 | # This will NOT load the entire stack into memory, only the current frame as needed. 394 | # This way even huge image stacks can be loaded and examined almost instantly. 395 | viewer.open() 396 | 397 | # Show viewer and run application. 398 | viewer.show() 399 | sys.exit(app.exec()) 400 | -------------------------------------------------------------------------------- /PyQtImageViewer/QtImageViewer.py: -------------------------------------------------------------------------------- 1 | """ QtImageViewer.py: PyQt image viewer widget based on QGraphicsView with mouse zooming/panning and ROIs. 2 | 3 | """ 4 | 5 | import os.path 6 | 7 | try: 8 | from PyQt6.QtCore import Qt, QRectF, QPoint, QPointF, pyqtSignal, QEvent, QSize 9 | from PyQt6.QtGui import QImage, QPixmap, QPainterPath, QMouseEvent, QPainter, QPen 10 | from PyQt6.QtWidgets import QGraphicsView, QGraphicsScene, QFileDialog, QSizePolicy, \ 11 | QGraphicsItem, QGraphicsEllipseItem, QGraphicsRectItem, QGraphicsLineItem, QGraphicsPolygonItem 12 | except ImportError: 13 | try: 14 | from PyQt5.QtCore import Qt, QRectF, QPoint, QPointF, pyqtSignal, QEvent, QSize 15 | from PyQt5.QtGui import QImage, QPixmap, QPainterPath, QMouseEvent, QPainter, QPen 16 | from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene, QFileDialog, QSizePolicy, \ 17 | QGraphicsItem, QGraphicsEllipseItem, QGraphicsRectItem, QGraphicsLineItem, QGraphicsPolygonItem 18 | except ImportError: 19 | raise ImportError("Requires PyQt (version 5 or 6)") 20 | 21 | # numpy is optional: only needed if you want to display numpy 2d arrays as images. 22 | try: 23 | import numpy as np 24 | except ImportError: 25 | np = None 26 | 27 | # qimage2ndarray is optional: useful for displaying numpy 2d arrays as images. 28 | # !!! qimage2ndarray requires PyQt5. 29 | # Some custom code in the viewer appears to handle the conversion from numpy 2d arrays, 30 | # so qimage2ndarray probably is not needed anymore. I've left it here just in case. 31 | try: 32 | import qimage2ndarray 33 | except ImportError: 34 | qimage2ndarray = None 35 | 36 | __author__ = "Marcel Goldschen-Ohm " 37 | __version__ = '2.0.0' 38 | 39 | 40 | class QtImageViewer(QGraphicsView): 41 | """ PyQt image viewer widget based on QGraphicsView with mouse zooming/panning and ROIs. 42 | 43 | Image File: 44 | ----------- 45 | Use the open("path/to/file") method to load an image file into the viewer. 46 | Calling open() without a file argument will popup a file selection dialog. 47 | 48 | Image: 49 | ------ 50 | Use the setImage(im) method to set the image data in the viewer. 51 | - im can be a QImage, QPixmap, or NumPy 2D array (the later requires the package qimage2ndarray). 52 | For display in the QGraphicsView the image will be converted to a QPixmap. 53 | 54 | Some useful image format conversion utilities: 55 | qimage2ndarray: NumPy ndarray <==> QImage (https://github.com/hmeine/qimage2ndarray) 56 | ImageQt: PIL Image <==> QImage (https://github.com/python-pillow/Pillow/blob/master/PIL/ImageQt.py) 57 | 58 | Mouse: 59 | ------ 60 | Mouse interactions for zooming and panning is fully customizable by simply setting the desired button interactions: 61 | e.g., 62 | regionZoomButton = Qt.LeftButton # Drag a zoom box. 63 | zoomOutButton = Qt.RightButton # Pop end of zoom stack (double click clears zoom stack). 64 | panButton = Qt.MiddleButton # Drag to pan. 65 | wheelZoomFactor = 1.25 # Set to None or 1 to disable mouse wheel zoom. 66 | 67 | To disable any interaction, just disable its button. 68 | e.g., to disable panning: 69 | panButton = None 70 | 71 | ROIs: 72 | ----- 73 | Can also add ellipse, rectangle, line, and polygon ROIs to the image. 74 | ROIs should be derived from the provided EllipseROI, RectROI, LineROI, and PolygonROI classes. 75 | ROIs are selectable and optionally moveable with the mouse (see setROIsAreMovable). 76 | 77 | TODO: Add support for editing the displayed image contrast. 78 | TODO: Add support for drawing ROIs with the mouse. 79 | """ 80 | 81 | # Mouse button signals emit image scene (x, y) coordinates. 82 | # !!! For image (row, column) matrix indexing, row = y and column = x. 83 | # !!! These signals will NOT be emitted if the event is handled by an interaction such as zoom or pan. 84 | # !!! If aspect ratio prevents image from filling viewport, emitted position may be outside image bounds. 85 | leftMouseButtonPressed = pyqtSignal(float, float) 86 | leftMouseButtonReleased = pyqtSignal(float, float) 87 | middleMouseButtonPressed = pyqtSignal(float, float) 88 | middleMouseButtonReleased = pyqtSignal(float, float) 89 | rightMouseButtonPressed = pyqtSignal(float, float) 90 | rightMouseButtonReleased = pyqtSignal(float, float) 91 | leftMouseButtonDoubleClicked = pyqtSignal(float, float) 92 | rightMouseButtonDoubleClicked = pyqtSignal(float, float) 93 | 94 | # Emitted upon zooming/panning. 95 | viewChanged = pyqtSignal() 96 | 97 | # Emitted on mouse motion. 98 | # Emits mouse position over image in image pixel coordinates. 99 | # !!! setMouseTracking(True) if you want to use this at all times. 100 | mousePositionOnImageChanged = pyqtSignal(QPoint) 101 | 102 | # Emit index of selected ROI 103 | roiSelected = pyqtSignal(int) 104 | 105 | def __init__(self, parent=None): 106 | QGraphicsView.__init__(self, parent) 107 | 108 | # Image is displayed as a QPixmap in a QGraphicsScene attached to this QGraphicsView. 109 | self.scene = QGraphicsScene() 110 | self.setScene(self.scene) 111 | 112 | # Better quality pixmap scaling? 113 | # self.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform) 114 | 115 | # Displayed image pixmap in the QGraphicsScene. 116 | self._image = None 117 | 118 | # Image aspect ratio mode. 119 | # Qt.IgnoreAspectRatio: Scale image to fit viewport. 120 | # Qt.KeepAspectRatio: Scale image to fit inside viewport, preserving aspect ratio. 121 | # Qt.KeepAspectRatioByExpanding: Scale image to fill the viewport, preserving aspect ratio. 122 | self.aspectRatioMode = Qt.AspectRatioMode.KeepAspectRatio 123 | 124 | # Scroll bar behaviour. 125 | # Qt.ScrollBarAlwaysOff: Never shows a scroll bar. 126 | # Qt.ScrollBarAlwaysOn: Always shows a scroll bar. 127 | # Qt.ScrollBarAsNeeded: Shows a scroll bar only when zoomed. 128 | self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 129 | self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 130 | 131 | # Interactions (set buttons to None to disable interactions) 132 | # !!! Events handled by interactions will NOT emit *MouseButton* signals. 133 | # Note: regionZoomButton will still emit a *MouseButtonReleased signal on a click (i.e. tiny box). 134 | self.regionZoomButton = Qt.MouseButton.LeftButton # Drag a zoom box. 135 | self.zoomOutButton = Qt.MouseButton.RightButton # Pop end of zoom stack (double click clears zoom stack). 136 | self.panButton = Qt.MouseButton.MiddleButton # Drag to pan. 137 | self.wheelZoomFactor = 1.25 # Set to None or 1 to disable mouse wheel zoom. 138 | 139 | # Stack of QRectF zoom boxes in scene coordinates. 140 | # !!! If you update this manually, be sure to call updateViewer() to reflect any changes. 141 | self.zoomStack = [] 142 | 143 | # Flags for active zooming/panning. 144 | self._isZooming = False 145 | self._isPanning = False 146 | 147 | # Store temporary position in screen pixels or scene units. 148 | self._pixelPosition = QPoint() 149 | self._scenePosition = QPointF() 150 | 151 | # Track mouse position. e.g., For displaying coordinates in a UI. 152 | # self.setMouseTracking(True) 153 | 154 | # ROIs. 155 | self.ROIs = [] 156 | 157 | # # For drawing ROIs. 158 | # self.drawROI = None 159 | 160 | self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 161 | 162 | def sizeHint(self): 163 | return QSize(900, 600) 164 | 165 | def hasImage(self): 166 | """ Returns whether the scene contains an image pixmap. 167 | """ 168 | return self._image is not None 169 | 170 | def clearImage(self): 171 | """ Removes the current image pixmap from the scene if it exists. 172 | """ 173 | if self.hasImage(): 174 | self.scene.removeItem(self._image) 175 | self._image = None 176 | 177 | def pixmap(self): 178 | """ Returns the scene's current image pixmap as a QPixmap, or else None if no image exists. 179 | :rtype: QPixmap | None 180 | """ 181 | if self.hasImage(): 182 | return self._image.pixmap() 183 | return None 184 | 185 | def image(self): 186 | """ Returns the scene's current image pixmap as a QImage, or else None if no image exists. 187 | :rtype: QImage | None 188 | """ 189 | if self.hasImage(): 190 | return self._image.pixmap().toImage() 191 | return None 192 | 193 | def setImage(self, image): 194 | """ Set the scene's current image pixmap to the input QImage or QPixmap. 195 | Raises a RuntimeError if the input image has type other than QImage or QPixmap. 196 | :type image: QImage | QPixmap 197 | """ 198 | if type(image) is QPixmap: 199 | pixmap = image 200 | elif type(image) is QImage: 201 | pixmap = QPixmap.fromImage(image) 202 | elif (np is not None) and (type(image) is np.ndarray): 203 | if qimage2ndarray is not None: 204 | qimage = qimage2ndarray.array2qimage(image, True) 205 | pixmap = QPixmap.fromImage(qimage) 206 | else: 207 | image = image.astype(np.float32) 208 | image -= image.min() 209 | image /= image.max() 210 | image *= 255 211 | image[image > 255] = 255 212 | image[image < 0] = 0 213 | image = image.astype(np.uint8) 214 | height, width = image.shape 215 | bytes = image.tobytes() 216 | qimage = QImage(bytes, width, height, QImage.Format.Format_Grayscale8) 217 | pixmap = QPixmap.fromImage(qimage) 218 | else: 219 | raise RuntimeError("ImageViewer.setImage: Argument must be a QImage, QPixmap, or numpy.ndarray.") 220 | if self.hasImage(): 221 | self._image.setPixmap(pixmap) 222 | else: 223 | self._image = self.scene.addPixmap(pixmap) 224 | 225 | # Better quality pixmap scaling? 226 | # !!! This will distort actual pixel data when zoomed way in. 227 | # For scientific image analysis, you probably don't want this. 228 | # self._pixmap.setTransformationMode(Qt.SmoothTransformation) 229 | 230 | self.setSceneRect(QRectF(pixmap.rect())) # Set scene size to image size. 231 | self.updateViewer() 232 | 233 | def open(self, filepath=None): 234 | """ Load an image from file. 235 | Without any arguments, loadImageFromFile() will pop up a file dialog to choose the image file. 236 | With a fileName argument, loadImageFromFile(fileName) will attempt to load the specified image file directly. 237 | """ 238 | if filepath is None: 239 | filepath, dummy = QFileDialog.getOpenFileName(self, "Open image file.") 240 | if len(filepath) and os.path.isfile(filepath): 241 | image = QImage(filepath) 242 | self.setImage(image) 243 | 244 | def updateViewer(self): 245 | """ Show current zoom (if showing entire image, apply current aspect ratio mode). 246 | """ 247 | if not self.hasImage(): 248 | return 249 | if len(self.zoomStack): 250 | self.fitInView(self.zoomStack[-1], self.aspectRatioMode) # Show zoomed rect. 251 | else: 252 | self.fitInView(self.sceneRect(), self.aspectRatioMode) # Show entire image. 253 | 254 | def clearZoom(self): 255 | if len(self.zoomStack) > 0: 256 | self.zoomStack = [] 257 | self.updateViewer() 258 | self.viewChanged.emit() 259 | 260 | def resizeEvent(self, event): 261 | """ Maintain current zoom on resize. 262 | """ 263 | self.updateViewer() 264 | 265 | def mousePressEvent(self, event): 266 | """ Start mouse pan or zoom mode. 267 | """ 268 | # Ignore dummy events. e.g., Faking pan with left button ScrollHandDrag. 269 | dummyModifiers = Qt.KeyboardModifier(Qt.KeyboardModifier.ShiftModifier | Qt.KeyboardModifier.ControlModifier 270 | | Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.MetaModifier) 271 | if event.modifiers() == dummyModifiers: 272 | QGraphicsView.mousePressEvent(self, event) 273 | event.accept() 274 | return 275 | 276 | # # Draw ROI 277 | # if self.drawROI is not None: 278 | # if self.drawROI == "Ellipse": 279 | # # Click and drag to draw ellipse. +Shift for circle. 280 | # pass 281 | # elif self.drawROI == "Rect": 282 | # # Click and drag to draw rectangle. +Shift for square. 283 | # pass 284 | # elif self.drawROI == "Line": 285 | # # Click and drag to draw line. 286 | # pass 287 | # elif self.drawROI == "Polygon": 288 | # # Click to add points to polygon. Double-click to close polygon. 289 | # pass 290 | 291 | # Start dragging a region zoom box? 292 | if (self.regionZoomButton is not None) and (event.button() == self.regionZoomButton): 293 | self._pixelPosition = event.pos() # store pixel position 294 | self.setDragMode(QGraphicsView.DragMode.RubberBandDrag) 295 | QGraphicsView.mousePressEvent(self, event) 296 | event.accept() 297 | self._isZooming = True 298 | return 299 | 300 | if (self.zoomOutButton is not None) and (event.button() == self.zoomOutButton): 301 | if len(self.zoomStack): 302 | self.zoomStack.pop() 303 | self.updateViewer() 304 | self.viewChanged.emit() 305 | event.accept() 306 | return 307 | 308 | # Start dragging to pan? 309 | if (self.panButton is not None) and (event.button() == self.panButton): 310 | self._pixelPosition = event.pos() # store pixel position 311 | self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) 312 | if self.panButton == Qt.MouseButton.LeftButton: 313 | QGraphicsView.mousePressEvent(self, event) 314 | else: 315 | # ScrollHandDrag ONLY works with LeftButton, so fake it. 316 | # Use a bunch of dummy modifiers to notify that event should NOT be handled as usual. 317 | self.viewport().setCursor(Qt.CursorShape.ClosedHandCursor) 318 | dummyModifiers = Qt.KeyboardModifier(Qt.KeyboardModifier.ShiftModifier 319 | | Qt.KeyboardModifier.ControlModifier 320 | | Qt.KeyboardModifier.AltModifier 321 | | Qt.KeyboardModifier.MetaModifier) 322 | dummyEvent = QMouseEvent(QEvent.Type.MouseButtonPress, QPointF(event.pos()), Qt.MouseButton.LeftButton, 323 | event.buttons(), dummyModifiers) 324 | self.mousePressEvent(dummyEvent) 325 | sceneViewport = self.mapToScene(self.viewport().rect()).boundingRect().intersected(self.sceneRect()) 326 | self._scenePosition = sceneViewport.topLeft() 327 | event.accept() 328 | self._isPanning = True 329 | return 330 | 331 | scenePos = self.mapToScene(event.pos()) 332 | if event.button() == Qt.MouseButton.LeftButton: 333 | self.leftMouseButtonPressed.emit(scenePos.x(), scenePos.y()) 334 | elif event.button() == Qt.MouseButton.MiddleButton: 335 | self.middleMouseButtonPressed.emit(scenePos.x(), scenePos.y()) 336 | elif event.button() == Qt.MouseButton.RightButton: 337 | self.rightMouseButtonPressed.emit(scenePos.x(), scenePos.y()) 338 | 339 | QGraphicsView.mousePressEvent(self, event) 340 | 341 | def mouseReleaseEvent(self, event): 342 | """ Stop mouse pan or zoom mode (apply zoom if valid). 343 | """ 344 | # Ignore dummy events. e.g., Faking pan with left button ScrollHandDrag. 345 | dummyModifiers = Qt.KeyboardModifier(Qt.KeyboardModifier.ShiftModifier | Qt.KeyboardModifier.ControlModifier 346 | | Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.MetaModifier) 347 | if event.modifiers() == dummyModifiers: 348 | QGraphicsView.mouseReleaseEvent(self, event) 349 | event.accept() 350 | return 351 | 352 | # Finish dragging a region zoom box? 353 | if (self.regionZoomButton is not None) and (event.button() == self.regionZoomButton): 354 | QGraphicsView.mouseReleaseEvent(self, event) 355 | zoomRect = self.scene.selectionArea().boundingRect().intersected(self.sceneRect()) 356 | # Clear current selection area (i.e. rubberband rect). 357 | self.scene.setSelectionArea(QPainterPath()) 358 | self.setDragMode(QGraphicsView.DragMode.NoDrag) 359 | # If zoom box is 3x3 screen pixels or smaller, do not zoom and proceed to process as a click release. 360 | zoomPixelWidth = abs(event.pos().x() - self._pixelPosition.x()) 361 | zoomPixelHeight = abs(event.pos().y() - self._pixelPosition.y()) 362 | if zoomPixelWidth > 3 and zoomPixelHeight > 3: 363 | if zoomRect.isValid() and (zoomRect != self.sceneRect()): 364 | self.zoomStack.append(zoomRect) 365 | self.updateViewer() 366 | self.viewChanged.emit() 367 | event.accept() 368 | self._isZooming = False 369 | return 370 | 371 | # Finish panning? 372 | if (self.panButton is not None) and (event.button() == self.panButton): 373 | if self.panButton == Qt.MouseButton.LeftButton: 374 | QGraphicsView.mouseReleaseEvent(self, event) 375 | else: 376 | # ScrollHandDrag ONLY works with LeftButton, so fake it. 377 | # Use a bunch of dummy modifiers to notify that event should NOT be handled as usual. 378 | self.viewport().setCursor(Qt.CursorShape.ArrowCursor) 379 | dummyModifiers = Qt.KeyboardModifier(Qt.KeyboardModifier.ShiftModifier 380 | | Qt.KeyboardModifier.ControlModifier 381 | | Qt.KeyboardModifier.AltModifier 382 | | Qt.KeyboardModifier.MetaModifier) 383 | dummyEvent = QMouseEvent(QEvent.Type.MouseButtonRelease, QPointF(event.pos()), 384 | Qt.MouseButton.LeftButton, event.buttons(), dummyModifiers) 385 | self.mouseReleaseEvent(dummyEvent) 386 | self.setDragMode(QGraphicsView.DragMode.NoDrag) 387 | if len(self.zoomStack) > 0: 388 | sceneViewport = self.mapToScene(self.viewport().rect()).boundingRect().intersected(self.sceneRect()) 389 | delta = sceneViewport.topLeft() - self._scenePosition 390 | self.zoomStack[-1].translate(delta) 391 | self.zoomStack[-1] = self.zoomStack[-1].intersected(self.sceneRect()) 392 | self.viewChanged.emit() 393 | event.accept() 394 | self._isPanning = False 395 | return 396 | 397 | scenePos = self.mapToScene(event.pos()) 398 | if event.button() == Qt.MouseButton.LeftButton: 399 | self.leftMouseButtonReleased.emit(scenePos.x(), scenePos.y()) 400 | elif event.button() == Qt.MouseButton.MiddleButton: 401 | self.middleMouseButtonReleased.emit(scenePos.x(), scenePos.y()) 402 | elif event.button() == Qt.MouseButton.RightButton: 403 | self.rightMouseButtonReleased.emit(scenePos.x(), scenePos.y()) 404 | 405 | QGraphicsView.mouseReleaseEvent(self, event) 406 | 407 | def mouseDoubleClickEvent(self, event): 408 | """ Show entire image. 409 | """ 410 | # Zoom out on double click? 411 | if (self.zoomOutButton is not None) and (event.button() == self.zoomOutButton): 412 | self.clearZoom() 413 | event.accept() 414 | return 415 | 416 | scenePos = self.mapToScene(event.pos()) 417 | if event.button() == Qt.MouseButton.LeftButton: 418 | self.leftMouseButtonDoubleClicked.emit(scenePos.x(), scenePos.y()) 419 | elif event.button() == Qt.MouseButton.RightButton: 420 | self.rightMouseButtonDoubleClicked.emit(scenePos.x(), scenePos.y()) 421 | 422 | QGraphicsView.mouseDoubleClickEvent(self, event) 423 | 424 | def wheelEvent(self, event): 425 | if self.wheelZoomFactor is not None: 426 | if self.wheelZoomFactor == 1: 427 | return 428 | if event.angleDelta().y() < 0: 429 | # zoom in 430 | if len(self.zoomStack) == 0: 431 | self.zoomStack.append(self.sceneRect()) 432 | elif len(self.zoomStack) > 1: 433 | del self.zoomStack[:-1] 434 | zoomRect = self.zoomStack[-1] 435 | center = zoomRect.center() 436 | zoomRect.setWidth(zoomRect.width() / self.wheelZoomFactor) 437 | zoomRect.setHeight(zoomRect.height() / self.wheelZoomFactor) 438 | zoomRect.moveCenter(center) 439 | self.zoomStack[-1] = zoomRect.intersected(self.sceneRect()) 440 | self.updateViewer() 441 | self.viewChanged.emit() 442 | else: 443 | # zoom out 444 | if len(self.zoomStack) == 0: 445 | # Already fully zoomed out. 446 | return 447 | if len(self.zoomStack) > 1: 448 | del self.zoomStack[:-1] 449 | zoomRect = self.zoomStack[-1] 450 | center = zoomRect.center() 451 | zoomRect.setWidth(zoomRect.width() * self.wheelZoomFactor) 452 | zoomRect.setHeight(zoomRect.height() * self.wheelZoomFactor) 453 | zoomRect.moveCenter(center) 454 | self.zoomStack[-1] = zoomRect.intersected(self.sceneRect()) 455 | if self.zoomStack[-1] == self.sceneRect(): 456 | self.zoomStack = [] 457 | self.updateViewer() 458 | self.viewChanged.emit() 459 | event.accept() 460 | return 461 | 462 | QGraphicsView.wheelEvent(self, event) 463 | 464 | def mouseMoveEvent(self, event): 465 | # Emit updated view during panning. 466 | if self._isPanning: 467 | QGraphicsView.mouseMoveEvent(self, event) 468 | if len(self.zoomStack) > 0: 469 | sceneViewport = self.mapToScene(self.viewport().rect()).boundingRect().intersected(self.sceneRect()) 470 | delta = sceneViewport.topLeft() - self._scenePosition 471 | self._scenePosition = sceneViewport.topLeft() 472 | self.zoomStack[-1].translate(delta) 473 | self.zoomStack[-1] = self.zoomStack[-1].intersected(self.sceneRect()) 474 | self.updateViewer() 475 | self.viewChanged.emit() 476 | 477 | scenePos = self.mapToScene(event.pos()) 478 | if self.sceneRect().contains(scenePos): 479 | # Pixel index offset from pixel center. 480 | x = int(round(scenePos.x() - 0.5)) 481 | y = int(round(scenePos.y() - 0.5)) 482 | imagePos = QPoint(x, y) 483 | else: 484 | # Invalid pixel position. 485 | imagePos = QPoint(-1, -1) 486 | self.mousePositionOnImageChanged.emit(imagePos) 487 | 488 | QGraphicsView.mouseMoveEvent(self, event) 489 | 490 | def enterEvent(self, event): 491 | self.setCursor(Qt.CursorShape.CrossCursor) 492 | 493 | def leaveEvent(self, event): 494 | self.setCursor(Qt.CursorShape.ArrowCursor) 495 | 496 | def addROIs(self, rois): 497 | for roi in rois: 498 | self.scene.addItem(roi) 499 | self.ROIs.append(roi) 500 | 501 | def deleteROIs(self, rois): 502 | for roi in rois: 503 | self.scene.removeItem(roi) 504 | self.ROIs.remove(roi) 505 | del roi 506 | 507 | def clearROIs(self): 508 | for roi in self.ROIs: 509 | self.scene.removeItem(roi) 510 | del self.ROIs[:] 511 | 512 | def roiClicked(self, roi): 513 | for i in range(len(self.ROIs)): 514 | if roi is self.ROIs[i]: 515 | self.roiSelected.emit(i) 516 | print(i) 517 | break 518 | 519 | def setROIsAreMovable(self, tf): 520 | if tf: 521 | for roi in self.ROIs: 522 | roi.setFlags(roi.flags() | QGraphicsItem.GraphicsItemFlag.ItemIsMovable) 523 | else: 524 | for roi in self.ROIs: 525 | roi.setFlags(roi.flags() & ~QGraphicsItem.GraphicsItemFlag.ItemIsMovable) 526 | 527 | def addSpots(self, xy, radius): 528 | for xy_ in xy: 529 | x, y = xy_ 530 | spot = EllipseROI(self) 531 | spot.setRect(x - radius, y - radius, 2 * radius, 2 * radius) 532 | self.scene.addItem(spot) 533 | self.ROIs.append(spot) 534 | 535 | 536 | class EllipseROI(QGraphicsEllipseItem): 537 | 538 | def __init__(self, viewer): 539 | QGraphicsItem.__init__(self) 540 | self._viewer = viewer 541 | pen = QPen(Qt.yellow) 542 | pen.setCosmetic(True) 543 | self.setPen(pen) 544 | self.setFlags(self.GraphicsItemFlag.ItemIsSelectable) 545 | 546 | def mousePressEvent(self, event): 547 | QGraphicsItem.mousePressEvent(self, event) 548 | if event.button() == Qt.MouseButton.LeftButton: 549 | self._viewer.roiClicked(self) 550 | 551 | 552 | class RectROI(QGraphicsRectItem): 553 | 554 | def __init__(self, viewer): 555 | QGraphicsItem.__init__(self) 556 | self._viewer = viewer 557 | pen = QPen(Qt.GlobalColor.yellow) 558 | pen.setCosmetic(True) 559 | self.setPen(pen) 560 | self.setFlags(self.GraphicsItemFlag.ItemIsSelectable) 561 | 562 | def mousePressEvent(self, event): 563 | QGraphicsItem.mousePressEvent(self, event) 564 | if event.button() == Qt.MouseButton.LeftButton: 565 | self._viewer.roiClicked(self) 566 | 567 | 568 | class LineROI(QGraphicsLineItem): 569 | 570 | def __init__(self, viewer): 571 | QGraphicsItem.__init__(self) 572 | self._viewer = viewer 573 | pen = QPen(Qt.GlobalColor.yellow) 574 | pen.setCosmetic(True) 575 | self.setPen(pen) 576 | self.setFlags(self.GraphicsItemFlag.ItemIsSelectable) 577 | 578 | def mousePressEvent(self, event): 579 | QGraphicsItem.mousePressEvent(self, event) 580 | if event.button() == Qt.MouseButton.LeftButton: 581 | self._viewer.roiClicked(self) 582 | 583 | 584 | class PolygonROI(QGraphicsPolygonItem): 585 | 586 | def __init__(self, viewer): 587 | QGraphicsItem.__init__(self) 588 | self._viewer = viewer 589 | pen = QPen(Qt.GlobalColor.yellow) 590 | pen.setCosmetic(True) 591 | self.setPen(pen) 592 | self.setFlags(self.GraphicsItemFlag.ItemIsSelectable) 593 | 594 | def mousePressEvent(self, event): 595 | QGraphicsItem.mousePressEvent(self, event) 596 | if event.button() == Qt.MouseButton.LeftButton: 597 | self._viewer.roiClicked(self) 598 | 599 | 600 | if __name__ == '__main__': 601 | import sys 602 | try: 603 | from PyQt6.QtWidgets import QApplication 604 | except ImportError: 605 | from PyQt5.QtWidgets import QApplication 606 | 607 | def handleLeftClick(x, y): 608 | row = int(y) 609 | column = int(x) 610 | print("Clicked on image pixel (row="+str(row)+", column="+str(column)+")") 611 | 612 | def handleViewChange(): 613 | print("viewChanged") 614 | 615 | # Create the application. 616 | app = QApplication(sys.argv) 617 | 618 | # Create image viewer. 619 | viewer = QtImageViewer() 620 | 621 | # Open an image from file. 622 | viewer.open() 623 | 624 | # Handle left mouse clicks with custom slot. 625 | viewer.leftMouseButtonReleased.connect(handleLeftClick) 626 | 627 | # Show viewer and run application. 628 | viewer.show() 629 | sys.exit(app.exec()) 630 | -------------------------------------------------------------------------------- /PyQtImageViewer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcel-goldschen-ohm/PyQtImageViewer/a8b53249bbc13029744829c05c573d1c2a670c3c/PyQtImageViewer/__init__.py -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyQtImageViewer 2 | 3 | * `QtImageViewer`: Yet another [PyQt6 or PyQt5](https://www.riverbankcomputing.com/software/pyqt/intro) image viewer widget. Comes prepackaged with several easily configurable options for display (aspect ratio, scroll bars) and mouse interaction (zoom, pan, click signals). Also has limited support for ROIs. Displays a *QImage*, *QPixmap*, or *NumPy 2D array*. To display any other image format, you must first convert it to one of the supported formats (e.g., see [Pillow](https://github.com/python-pillow/Pillow), [ImageQt](https://github.com/python-pillow/Pillow/blob/master/PIL/ImageQt.py), and [qimage2ndarray](https://github.com/hmeine/qimage2ndarray)). 4 | * `QtImageStackViewer`: Multi-page image stack viewer similar to [ImageJ](https://imagej.nih.gov/ij/). Based off of QtImageViewer with sliders for traversing frames and/or channels and a titlebar that displays the current frame in the stack and mouse position coordinates like in ImageJ. Image stacks can either be specified directly as NumPy 3D arrays [rows, columns, frames] or multi-page image files can be read from file using [Pillow](https://github.com/python-pillow/Pillow). For image files, the data is loaded frame by frame as needed which avoids loading huge image stack files into memory and enables fast loading and scrolling through frames in the widget. 5 | 6 | **Author**: Marcel Goldschen-Ohm 7 | **Email**: 8 | **License**: MIT 9 | Copyright (c) 2022 Marcel Goldschen-Ohm 10 | 11 | # INSTALL 12 | 1. Ensure PyQt5 or PyQt6 is installed for your current enviroment. 13 | 2. `pip install git+https://github.com/marcel-goldschen-ohm/PyQtImageViewer` 14 | 15 | Everything required to run this package can also be found in `QtImageViewer.py` and `QtImageStackViewer.py`. Just put these somewhere where your project can find them. 16 | 17 | ### Requirements: 18 | 19 | * [PyQt6 or PyQt5](https://www.riverbankcomputing.com/software/pyqt/intro) - Not included in setup.py requirements due to macOS/Linux install issues for Apple Silicon Devices 20 | * [NumPy](https://numpy.org/) 21 | * [Pillow](https://python-pillow.org) 22 | 23 | # `QtImageViewer` Basic Example 24 | 25 | ```python 26 | import sys 27 | from PyQt6.QtCore import Qt 28 | from PyQt6.QtWidgets import QApplication 29 | from QtImageViewer import QtImageViewer 30 | 31 | 32 | # Custom slot for handling mouse clicks in our viewer. 33 | # This example just prints the (row, column) matrix index 34 | # of the image pixel that was clicked on. 35 | def handleLeftClick(x, y): 36 | row = int(y) 37 | column = int(x) 38 | print("Pixel (row="+str(row)+", column="+str(column)+")") 39 | 40 | 41 | if __name__ == '__main__': 42 | # Create the QApplication. 43 | app = QApplication(sys.argv) 44 | 45 | # Create an image viewer widget. 46 | viewer = QtImageViewer() 47 | 48 | # Set viewer's aspect ratio mode. 49 | # !!! ONLY applies to full image view. 50 | # !!! Aspect ratio always ignored when zoomed. 51 | # Qt.AspectRatioMode.IgnoreAspectRatio: Fit to viewport. 52 | # Qt.AspectRatioMode.KeepAspectRatio: Fit in viewport using aspect ratio. 53 | # Qt.AspectRatioMode.KeepAspectRatioByExpanding: Fill viewport using aspect ratio. 54 | viewer.aspectRatioMode = Qt.AspectRatioMode.KeepAspectRatio 55 | 56 | # Set the viewer's scroll bar behaviour. 57 | # Qt.ScrollBarPolicy.ScrollBarAlwaysOff: Never show scroll bar. 58 | # Qt.ScrollBarPolicy.ScrollBarAlwaysOn: Always show scroll bar. 59 | # Qt.ScrollBarPolicy.ScrollBarAsNeeded: Show scroll bar only when zoomed. 60 | viewer.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 61 | viewer.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 62 | 63 | # Allow zooming by draggin a zoom box with the left mouse button. 64 | # !!! This will still emit a leftMouseButtonReleased signal if no dragging occured, 65 | # so you can still handle left mouse button clicks in this way. 66 | # If you absolutely need to handle a left click upon press, then 67 | # either disable region zooming or set it to the middle or right button. 68 | viewer.regionZoomButton = Qt.MouseButton.LeftButton # set to None to disable 69 | 70 | # Pop end of zoom stack (double click clears zoom stack). 71 | viewer.zoomOutButton = Qt.MouseButton.RightButton # set to None to disable 72 | 73 | # Mouse wheel zooming. 74 | viewer.wheelZoomFactor = 1.25 # Set to None or 1 to disable 75 | 76 | # Allow panning with the middle mouse button. 77 | viewer.panButton = Qt.MouseButton.MiddleButton # set to None to disable 78 | 79 | # Load an image file to be displayed (will popup a file dialog). 80 | viewer.open() 81 | 82 | # Handle left mouse clicks with your own custom slot 83 | # handleLeftClick(x, y). (x, y) are image coordinates. 84 | # For (row, col) matrix indexing, row=y and col=x. 85 | # QtImageViewer also provides similar signals for 86 | # left/right mouse button press, release and doubleclick. 87 | # Here I bind the slot to leftMouseButtonReleased only because 88 | # the leftMouseButtonPressed signal will not be emitted due to 89 | # left clicks being handled by the regionZoomButton. 90 | viewer.leftMouseButtonReleased.connect(handleLeftClick) 91 | 92 | # Show the viewer and run the application. 93 | viewer.show() 94 | sys.exit(app.exec()) 95 | ``` 96 | 97 | # `QtImageStackViewer` Basic Example 98 | 99 | ```python 100 | import sys 101 | from PyQt6.QtCore import Qt 102 | from PyQt6.QtWidgets import QApplication 103 | from QtImageStackViewer import QtImageStackViewer 104 | 105 | 106 | if __name__ == '__main__': 107 | # Create the QApplication. 108 | app = QApplication(sys.argv) 109 | 110 | # Create an image stack viewer widget. 111 | viewer = QtImageStackViewer() 112 | 113 | # Customize mouse interaction via the QtImageViewer widget. 114 | viewer.imageViewer.regionZoomButton = Qt.MouseButton.LeftButton # set to None to disable 115 | viewer.imageViewer.zoomOutButton = Qt.MouseButton.RightButton # set to None to disable 116 | viewer.imageViewer.wheelZoomFactor = 1.25 # Set to None or 1 to disable 117 | viewer.imageViewer.panButton = Qt.MouseButton.MiddleButton # set to None to disable 118 | 119 | # Load an image stack file to be displayed (will popup a file dialog). 120 | viewer.open() 121 | 122 | # Show the viewer and run the application. 123 | viewer.show() 124 | sys.exit(app.exec()) 125 | ``` 126 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import pathlib 4 | from setuptools import setup, find_packages 5 | 6 | HERE = pathlib.Path(__file__).parent 7 | 8 | setup( 9 | 10 | ### Metadata 11 | 12 | name='PyQtImageViewer', 13 | 14 | version='2.0.0.post1', 15 | 16 | description='Yet another PyQt6 or PyQt5 image viewer widget', 17 | 18 | long_description=(HERE / "README.md").read_text(), 19 | long_desc_type = "text/markdown", 20 | 21 | url='https://github.com/marcel-goldschen-ohm/PyQtImageViewer', 22 | 23 | download_url='', 24 | 25 | license='MIT', 26 | 27 | author='Marcel Goldschen-Ohm', 28 | author_email='goldschen-ohm@utexas.edu', 29 | 30 | maintainer='John Doe', 31 | maintainer_email='john.doe@lavabit.com', 32 | 33 | classifiers=[ 34 | 'Development Status :: 5 - Production/Stable', 35 | 'Intended Audience :: Developers', 36 | 'License :: OSI Approved :: MIT License', 37 | 'Natural Language :: English', 38 | 'Operating System :: OS Independent', 39 | 'Programming Language :: Python :: 3', 40 | 'Topic :: Software Development :: Build Tools', 41 | ], 42 | 43 | ### Dependencies 44 | 45 | install_requires=[ 46 | 'numpy', 47 | 'Pillow', 48 | ], 49 | 50 | ### Contents 51 | 52 | packages=find_packages() 53 | ) 54 | --------------------------------------------------------------------------------