ImageViewer.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. """ ImageViewer.py: PyQt image viewer widget based on QGraphicsView with mouse croping.
  2. """
  3. import os.path
  4. from PySide6.QtCore import Qt, QRectF, QRect, QPoint, QPointF, QEvent, QSize, Signal
  5. from PySide6.QtGui import QImage, QPixmap, QPainterPath, QMouseEvent, QPainter, QPen
  6. from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QFileDialog, QSizePolicy, QMdiArea
  7. # numpy is optional: only needed if you want to display numpy 2d arrays as images.
  8. try:
  9. import numpy as np
  10. except ImportError:
  11. np = None
  12. # qimage2ndarray is optional: useful for displaying numpy 2d arrays as images.
  13. # !!! qimage2ndarray requires PyQt5.
  14. # Some custom code in the viewer appears to handle the conversion from numpy 2d arrays,
  15. # so qimage2ndarray probably is not needed anymore. I've left it here just in case.
  16. try:
  17. import qimage2ndarray
  18. except ImportError:
  19. qimage2ndarray = None
  20. __author__ = "Alex Sidorov <alex.sidorof@ya.ru>"
  21. __version__ = '0.1'
  22. class ImageViewer(QGraphicsView):
  23. """ PyQt image viewer widget based on QGraphicsView with mouse croping.
  24. Image File:
  25. -----------
  26. Calling set_file()
  27. Image:
  28. ------
  29. Use the setImage(im) method to set the image data in the viewer.
  30. - im can be a QImage, QPixmap, or NumPy 2D array (the later requires the package qimage2ndarray).
  31. For display in the QGraphicsView the image will be converted to a QPixmap.
  32. Some useful image format conversion utilities:
  33. qimage2ndarray: NumPy ndarray <==> QImage (https://github.com/hmeine/qimage2ndarray)
  34. ImageQt: PIL Image <==> QImage (https://github.com/python-pillow/Pillow/blob/master/PIL/ImageQt.py)
  35. Mouse:
  36. ------
  37. Mouse interactions for cropted and panning is fully customizable by simply setting the desired button interactions:
  38. e.g.,
  39. regionZoomButton = Qt.LeftButton # Drag a zoom box.
  40. zoomOutButton = Qt.RightButton # Pop end of zoom stack (double click clears zoom stack).
  41. panButton = Qt.MiddleButton # Drag to pan.
  42. wheelZoomFactor = 1.25 # Set to None or 1 to disable mouse wheel zoom.
  43. To disable any interaction, just disable its button.
  44. e.g., to disable panning:
  45. panButton = None
  46. """
  47. # Mouse button signals emit image scene (x, y) coordinates.
  48. # !!! For image (row, column) matrix indexing, row = y and column = x.
  49. # !!! These signals will NOT be emitted if the event is handled by an interaction such as zoom or pan.
  50. # !!! If aspect ratio prevents image from filling viewport, emitted position may be outside image bounds.
  51. leftMouseButtonPressed = Signal(float, float)
  52. leftMouseButtonReleased = Signal(float, float)
  53. middleMouseButtonPressed = Signal(float, float)
  54. middleMouseButtonReleased = Signal(float, float)
  55. rightMouseButtonPressed = Signal(float, float)
  56. rightMouseButtonReleased = Signal(float, float)
  57. leftMouseButtonDoubleClicked = Signal(float, float)
  58. rightMouseButtonDoubleClicked = Signal(float, float)
  59. # Emitted upon zooming/panning.
  60. viewChanged = Signal()
  61. # Emitted on mouse motion.
  62. # Emits mouse position over image in image pixel coordinates.
  63. # !!! setMouseTracking(True) if you want to use this at all times.
  64. mousePositionOnImageChanged = Signal(QPoint)
  65. def __init__(self, parent=None):
  66. super(ImageViewer, self).__init__(parent)
  67. # Image is displayed as a QPixmap in a QGraphicsScene attached to this QGraphicsView.
  68. self.scene = QGraphicsScene()
  69. self.setScene(self.scene)
  70. # Better quality pixmap scaling?
  71. # self.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform)
  72. # Displayed image pixmap in the QGraphicsScene.
  73. self._image: QPixmap = None
  74. # Image aspect ratio mode.
  75. # Qt.IgnoreAspectRatio: Scale image to fit viewport.
  76. # Qt.KeepAspectRatio: Scale image to fit inside viewport, preserving aspect ratio.
  77. # Qt.KeepAspectRatioByExpanding: Scale image to fill the viewport, preserving aspect ratio.
  78. self.aspectRatioMode = Qt.AspectRatioMode.KeepAspectRatio
  79. # Scroll bar behaviour.
  80. # Qt.ScrollBarAlwaysOff: Never shows a scroll bar.
  81. # Qt.ScrollBarAlwaysOn: Always shows a scroll bar.
  82. # Qt.ScrollBarAsNeeded: Shows a scroll bar only when zoomed.
  83. self.setHorizontalScrollBarPolicy(
  84. Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
  85. self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
  86. # Interactions (set buttons to None to disable interactions)
  87. # !!! Events handled by interactions will NOT emit *MouseButton* signals.
  88. # Note: regionZoomButton will still emit a *MouseButtonReleased signal on a click (i.e. tiny box).
  89. self.regionZoomButton = Qt.MouseButton.LeftButton # Drag a zoom box.
  90. # Pop end of zoom stack (double click clears zoom stack).
  91. self.zoomOutButton = Qt.MouseButton.RightButton
  92. self.panButton = Qt.MouseButton.MiddleButton # Drag to pan.
  93. # Set to None or 1 to disable mouse wheel zoom.
  94. self.wheelZoomFactor = 1.25
  95. # Stack of QRectF zoom boxes in scene coordinates.
  96. # !!! If you update this manually, be sure to call updateViewer() to reflect any changes.
  97. self.zoomStack: list[QRectF] = []
  98. #Stack of image
  99. self.imageStack: list[QPixmap] = []
  100. # Flags for active zooming/panning.
  101. self._isZooming: bool = False
  102. self._isPanning: bool = False
  103. # Store temporary position in screen pixels or scene units.
  104. self._pixelPosition = QPoint()
  105. self._scenePosition = QPointF()
  106. # Track mouse position. e.g., For displaying coordinates in a UI.
  107. # self.setMouseTracking(True)
  108. self.setSizePolicy(QSizePolicy.Policy.Expanding,
  109. QSizePolicy.Policy.Expanding)
  110. def sizeHint(self):
  111. return QSize(900, 600)
  112. def hasImage(self):
  113. """ Returns whether the scene contains an image pixmap.
  114. """
  115. return self._image is not None
  116. def clearImage(self):
  117. """ Removes the current image pixmap from the scene if it exists.
  118. """
  119. if self.hasImage():
  120. self.scene.removeItem(self._image)
  121. self._image = None
  122. def pixmap(self):
  123. """ Returns the scene's current image pixmap as a QPixmap, or else None if no image exists.
  124. :rtype: QPixmap | None
  125. """
  126. if self.hasImage():
  127. return self._image.pixmap()
  128. return None
  129. def image(self):
  130. """ Returns the scene's current image pixmap as a QImage, or else None if no image exists.
  131. :rtype: QImage | None
  132. """
  133. if self.hasImage():
  134. return self._image.pixmap().toImage()
  135. return None
  136. def setImage(self, image):
  137. """ Set the scene's current image pixmap to the input QImage or QPixmap.
  138. Raises a RuntimeError if the input image has type other than QImage or QPixmap.
  139. :type image: QImage | QPixmap
  140. """
  141. if type(image) is QPixmap:
  142. pixmap = image
  143. elif type(image) is QImage:
  144. pixmap = QPixmap.fromImage(image)
  145. elif type(image) is str:
  146. pixmap = QPixmap.fromImage(QImage(image))
  147. elif (np is not None) and (type(image) is np.ndarray):
  148. if qimage2ndarray is not None:
  149. qimage = qimage2ndarray.array2qimage(image, True)
  150. pixmap = QPixmap.fromImage(qimage)
  151. else:
  152. image = image.astype(np.float32)
  153. image -= image.min()
  154. image /= image.max()
  155. image *= 255
  156. image[image > 255] = 255
  157. image[image < 0] = 0
  158. image = image.astype(np.uint8)
  159. height, width = image.shape
  160. bytes = image.tobytes()
  161. qimage = QImage(bytes, width, height,
  162. QImage.Format.Format_Grayscale8)
  163. pixmap = QPixmap.fromImage(qimage)
  164. else:
  165. raise RuntimeError(
  166. "ImageViewer.setImage: Argument must be a QImage, QPixmap, or numpy.ndarray.")
  167. if self.hasImage():
  168. self._image.setPixmap(pixmap)
  169. else:
  170. self._image = self.scene.addPixmap(pixmap)
  171. self.imageStack.append(pixmap)
  172. # Better quality pixmap scaling?
  173. # !!! This will distort actual pixel data when zoomed way in.
  174. # For scientific image analysis, you probably don't want this.
  175. self._image.setTransformationMode(Qt.SmoothTransformation)
  176. # Set scene size to image size.
  177. self.setSceneRect(QRectF(pixmap.rect()))
  178. self.updateViewer()
  179. def updateViewer(self):
  180. """ Show current zoom (if showing entire image, apply current aspect ratio mode).
  181. """
  182. if not self.hasImage():
  183. return
  184. if len(self.imageStack) > 1:
  185. # Show zoomed rect.
  186. # self.fitInView(self.zoomStack[-1], self.aspectRatioMode)
  187. self._image.setPixmap(self.imageStack[-1])
  188. else:
  189. # Show entire image.
  190. self._image.setPixmap(self.imageStack[0])
  191. # self.fitInView(self.sceneRect(), self.aspectRatioMode)
  192. def clearZoom(self):
  193. if len(self.zoomStack) > 0:
  194. self.zoomStack = []
  195. self.imageStack = self.imageStack[0]
  196. self.updateViewer()
  197. self.viewChanged.emit()
  198. def resizeEvent(self, event):
  199. """ Maintain current zoom on resize.
  200. """
  201. self.updateViewer()
  202. def mousePressEvent(self, event):
  203. """ Start mouse pan or zoom mode.
  204. """
  205. # Ignore dummy events. e.g., Faking pan with left button ScrollHandDrag.
  206. dummyModifiers = Qt.KeyboardModifier(Qt.KeyboardModifier.ShiftModifier | Qt.KeyboardModifier.ControlModifier
  207. | Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.MetaModifier)
  208. if event.modifiers() == dummyModifiers:
  209. QGraphicsView.mousePressEvent(self, event)
  210. event.accept()
  211. return
  212. # Start dragging a region zoom box?
  213. if (self.regionZoomButton is not None) and (event.button() == self.regionZoomButton):
  214. self._pixelPosition = event.pos() # store pixel position
  215. self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
  216. QGraphicsView.mousePressEvent(self, event)
  217. event.accept()
  218. self._isZooming = True
  219. return
  220. if (self.zoomOutButton is not None) and (event.button() == self.zoomOutButton):
  221. if len(self.zoomStack):
  222. self.zoomStack.pop()
  223. self.imageStack.pop()
  224. self.updateViewer()
  225. self.viewChanged.emit()
  226. event.accept()
  227. return
  228. # Start dragging to pan?
  229. if (self.panButton is not None) and (event.button() == self.panButton):
  230. self._pixelPosition = event.pos() # store pixel position
  231. self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
  232. if self.panButton == Qt.MouseButton.LeftButton:
  233. QGraphicsView.mousePressEvent(self, event)
  234. else:
  235. # ScrollHandDrag ONLY works with LeftButton, so fake it.
  236. # Use a bunch of dummy modifiers to notify that event should NOT be handled as usual.
  237. self.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
  238. dummyModifiers = Qt.KeyboardModifier(Qt.KeyboardModifier.ShiftModifier
  239. | Qt.KeyboardModifier.ControlModifier
  240. | Qt.KeyboardModifier.AltModifier
  241. | Qt.KeyboardModifier.MetaModifier)
  242. dummyEvent = QMouseEvent(QEvent.Type.MouseButtonPress, QPointF(event.pos()), Qt.MouseButton.LeftButton,
  243. event.buttons(), dummyModifiers)
  244. self.mousePressEvent(dummyEvent)
  245. sceneViewport = self.mapToScene(
  246. self.viewport().rect()).boundingRect().intersected(self.sceneRect())
  247. self._scenePosition = sceneViewport.topLeft()
  248. event.accept()
  249. self._isPanning = True
  250. return
  251. scenePos = self.mapToScene(event.pos())
  252. if event.button() == Qt.MouseButton.LeftButton:
  253. self.leftMouseButtonPressed.emit(scenePos.x(), scenePos.y())
  254. elif event.button() == Qt.MouseButton.MiddleButton:
  255. self.middleMouseButtonPressed.emit(scenePos.x(), scenePos.y())
  256. elif event.button() == Qt.MouseButton.RightButton:
  257. self.rightMouseButtonPressed.emit(scenePos.x(), scenePos.y())
  258. QGraphicsView.mousePressEvent(self, event)
  259. def mouseReleaseEvent(self, event):
  260. """ Stop mouse pan or zoom mode (apply zoom if valid).
  261. """
  262. # Ignore dummy events. e.g., Faking pan with left button ScrollHandDrag.
  263. dummyModifiers = Qt.KeyboardModifier(Qt.KeyboardModifier.ShiftModifier | Qt.KeyboardModifier.ControlModifier
  264. | Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.MetaModifier)
  265. if event.modifiers() == dummyModifiers:
  266. QGraphicsView.mouseReleaseEvent(self, event)
  267. event.accept()
  268. return
  269. # Finish dragging a region zoom box?
  270. if (self.regionZoomButton is not None) and (event.button() == self.regionZoomButton):
  271. QGraphicsView.mouseReleaseEvent(self, event)
  272. zoomRect = self.scene.selectionArea().boundingRect().intersected(self.sceneRect())
  273. # Clear current selection area (i.e. rubberband rect).
  274. self.scene.setSelectionArea(QPainterPath())
  275. self.setDragMode(QGraphicsView.DragMode.NoDrag)
  276. # If zoom box is 3x3 screen pixels or smaller, do not zoom and proceed to process as a click release.
  277. zoomPixelWidth = abs(event.pos().x() - self._pixelPosition.x())
  278. zoomPixelHeight = abs(event.pos().y() - self._pixelPosition.y())
  279. if zoomPixelWidth > 3 and zoomPixelHeight > 3:
  280. if zoomRect.isValid() and (zoomRect != self.sceneRect()):
  281. self.zoomStack.append(zoomRect)
  282. self.crop_image()
  283. self.updateViewer()
  284. self.viewChanged.emit()
  285. event.accept()
  286. self._isZooming = False
  287. return
  288. # Finish panning?
  289. if (self.panButton is not None) and (event.button() == self.panButton):
  290. if self.panButton == Qt.MouseButton.LeftButton:
  291. QGraphicsView.mouseReleaseEvent(self, event)
  292. else:
  293. # ScrollHandDrag ONLY works with LeftButton, so fake it.
  294. # Use a bunch of dummy modifiers to notify that event should NOT be handled as usual.
  295. self.viewport().setCursor(Qt.CursorShape.ArrowCursor)
  296. dummyModifiers = Qt.KeyboardModifier(Qt.KeyboardModifier.ShiftModifier
  297. | Qt.KeyboardModifier.ControlModifier
  298. | Qt.KeyboardModifier.AltModifier
  299. | Qt.KeyboardModifier.MetaModifier)
  300. dummyEvent = QMouseEvent(QEvent.Type.MouseButtonRelease, QPointF(event.pos()),
  301. Qt.MouseButton.LeftButton, event.buttons(), dummyModifiers)
  302. self.mouseReleaseEvent(dummyEvent)
  303. self.setDragMode(QGraphicsView.DragMode.NoDrag)
  304. if len(self.zoomStack) > 0:
  305. sceneViewport = self.mapToScene(
  306. self.viewport().rect()).boundingRect().intersected(self.sceneRect())
  307. delta = sceneViewport.topLeft() - self._scenePosition
  308. self.zoomStack[-1].translate(delta)
  309. self.zoomStack[-1] = self.zoomStack[-1].intersected(
  310. self.sceneRect())
  311. self.viewChanged.emit()
  312. event.accept()
  313. self._isPanning = False
  314. return
  315. scenePos = self.mapToScene(event.pos())
  316. if event.button() == Qt.MouseButton.LeftButton:
  317. self.leftMouseButtonReleased.emit(scenePos.x(), scenePos.y())
  318. elif event.button() == Qt.MouseButton.MiddleButton:
  319. self.middleMouseButtonReleased.emit(scenePos.x(), scenePos.y())
  320. elif event.button() == Qt.MouseButton.RightButton:
  321. self.rightMouseButtonReleased.emit(scenePos.x(), scenePos.y())
  322. QGraphicsView.mouseReleaseEvent(self, event)
  323. def mouseDoubleClickEvent(self, event):
  324. """ Show entire image.
  325. """
  326. # Zoom out on double click?
  327. if (self.zoomOutButton is not None) and (event.button() == self.zoomOutButton):
  328. self.clearZoom()
  329. event.accept()
  330. return
  331. scenePos = self.mapToScene(event.pos())
  332. if event.button() == Qt.MouseButton.LeftButton:
  333. self.leftMouseButtonDoubleClicked.emit(scenePos.x(), scenePos.y())
  334. elif event.button() == Qt.MouseButton.RightButton:
  335. self.rightMouseButtonDoubleClicked.emit(scenePos.x(), scenePos.y())
  336. QGraphicsView.mouseDoubleClickEvent(self, event)
  337. def mouseMoveEvent(self, event):
  338. # Emit updated view during panning.
  339. if self._isPanning:
  340. QGraphicsView.mouseMoveEvent(self, event)
  341. if len(self.zoomStack) > 0:
  342. sceneViewport = self.mapToScene(
  343. self.viewport().rect()).boundingRect().intersected(self.sceneRect())
  344. delta = sceneViewport.topLeft() - self._scenePosition
  345. self._scenePosition = sceneViewport.topLeft()
  346. self.zoomStack[-1].translate(delta)
  347. self.zoomStack[-1] = self.zoomStack[-1].intersected(
  348. self.sceneRect())
  349. self.updateViewer()
  350. self.viewChanged.emit()
  351. scenePos = self.mapToScene(event.pos())
  352. if self.sceneRect().contains(scenePos):
  353. # Pixel index offset from pixel center.
  354. x = int(round(scenePos.x() - 0.5))
  355. y = int(round(scenePos.y() - 0.5))
  356. imagePos = QPoint(x, y)
  357. else:
  358. # Invalid pixel position.
  359. imagePos = QPoint(-1, -1)
  360. self.mousePositionOnImageChanged.emit(imagePos)
  361. QGraphicsView.mouseMoveEvent(self, event)
  362. def enterEvent(self, event):
  363. pass
  364. # self.setCursor(Qt.CursorShape.CrossCursor)
  365. def leaveEvent(self, event):
  366. pass
  367. # self.setCursor(Qt.CursorShape.ArrowCursor)
  368. def crop_image(self) -> QPixmap:
  369. if self.hasImage():
  370. rect: QRect = self.zoomStack[-1].toRect()
  371. image = self.imageStack[-1].copy(rect)
  372. self._image.setPixmap(image)
  373. self.imageStack.append(image)
  374. def save_image(self, path_file: str) -> None:
  375. self._image.save(path_file)