1 # Copyright (c) 2018-2024 David Aguilar <davvid@gmail.com>
3 # Git Cola is GPL licensed, but this file has a more permissive license.
4 # This file is dual-licensed Git Cola GPL + pyqimageview MIT.
5 # imageview.py was originally based on the pyqimageview:
6 # https://github.com/nevion/pyqimageview/
8 # The MIT License (MIT)
10 # Copyright (c) 2014 Jason Newton <nevion@gmail.com>
12 # Permission is hereby granted, free of charge, to any person obtaining a copy
13 # of this software and associated documentation files (the "Software"), to deal
14 # in the Software without restriction, including without limitation the rights
15 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16 # copies of the Software, and to permit persons to whom the Software is
17 # furnished to do so, subject to the following conditions:
19 # The above copyright notice and this permission notice shall be included in
20 # all copies or substantial portions of the Software.
22 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
33 from qtpy
import QtCore
34 from qtpy
import QtGui
35 from qtpy
import QtWidgets
36 from qtpy
.QtCore
import Qt
37 from qtpy
.QtCore
import Signal
46 from .. import qtcompat
52 return max(min(x
, hi
), lo
)
55 class ImageView(QtWidgets
.QGraphicsView
):
56 image_changed
= Signal()
58 def __init__(self
, parent
=None):
59 super().__init
__(parent
)
61 scene
= QtWidgets
.QGraphicsScene(self
)
62 self
.graphics_pixmap
= QtWidgets
.QGraphicsPixmapItem()
63 scene
.addItem(self
.graphics_pixmap
)
66 self
.zoom_factor
= 1.125
67 self
.rubberband
= None
69 self
.first_show_occured
= False
70 self
.last_scene_roi
= None
71 self
.start_drag
= QtCore
.QPoint()
72 self
.checkerboard
= None
77 check_pattern
= QtGui
.QImage(
78 CHECK_MEDIUM
* 2, CHECK_MEDIUM
* 2, QtGui
.QImage
.Format_RGB888
80 color_gray
= QtGui
.QColor(CHECK_GRAY
, CHECK_GRAY
, CHECK_GRAY
)
81 color_light
= QtGui
.QColor(CHECK_LIGHT
, CHECK_LIGHT
, CHECK_LIGHT
)
82 painter
= QtGui
.QPainter(check_pattern
)
83 painter
.fillRect(0, 0, CHECK_MEDIUM
, CHECK_MEDIUM
, color_gray
)
85 CHECK_MEDIUM
, CHECK_MEDIUM
, CHECK_MEDIUM
, CHECK_MEDIUM
, color_gray
87 painter
.fillRect(0, CHECK_MEDIUM
, CHECK_MEDIUM
, CHECK_MEDIUM
, color_light
)
88 painter
.fillRect(CHECK_MEDIUM
, 0, CHECK_MEDIUM
, CHECK_MEDIUM
, color_light
)
89 self
.check_pattern
= check_pattern
90 self
.check_brush
= QtGui
.QBrush(check_pattern
)
92 def load(self
, filename
):
93 image
= QtGui
.QImage()
95 ok
= not image
.isNull()
102 return self
.graphics_pixmap
.pixmap()
105 def pixmap(self
, image
, image_format
=None):
107 if have_numpy
and isinstance(image
, np
.ndarray
):
109 if image
.shape
[2] == 3:
110 if image_format
is None:
111 image_format
= QtGui
.QImage
.Format_RGB888
112 q_image
= QtGui
.QImage(
113 image
.data
, image
.shape
[1], image
.shape
[0], image_format
115 pixmap
= QtGui
.QPixmap
.fromImage(q_image
)
116 elif image
.shape
[2] == 4:
117 if image_format
is None:
118 image_format
= QtGui
.QImage
.Format_RGB32
119 q_image
= QtGui
.QImage(
120 image
.data
, image
.shape
[1], image
.shape
[0], image_format
122 pixmap
= QtGui
.QPixmap
.fromImage(q_image
)
124 raise TypeError(image
)
125 elif image
.ndim
== 2:
126 if image_format
is None:
127 image_format
= QtGui
.QImage
.Format_RGB888
128 q_image
= QtGui
.QImage(
129 image
.data
, image
.shape
[1], image
.shape
[0], image_format
131 pixmap
= QtGui
.QPixmap
.fromImage(q_image
)
133 raise ValueError(image
)
135 elif isinstance(image
, QtGui
.QImage
):
136 pixmap
= QtGui
.QPixmap
.fromImage(image
)
137 elif isinstance(image
, QtGui
.QPixmap
):
140 raise TypeError(image
)
142 if pixmap
.hasAlpha():
143 checkerboard
= QtGui
.QImage(
144 pixmap
.width(), pixmap
.height(), QtGui
.QImage
.Format_ARGB32
146 self
.checkerboard
= checkerboard
147 painter
= QtGui
.QPainter(checkerboard
)
148 painter
.fillRect(checkerboard
.rect(), self
.check_brush
)
149 painter
.drawPixmap(0, 0, pixmap
)
150 pixmap
= QtGui
.QPixmap
.fromImage(checkerboard
)
152 self
.graphics_pixmap
.setPixmap(pixmap
)
153 self
.update_scene_rect()
154 self
.fitInView(self
.image_scene_rect
, flags
=Qt
.KeepAspectRatio
)
155 self
.graphics_pixmap
.update()
156 self
.image_changed
.emit()
158 # image property alias
164 def image(self
, image
):
167 def update_scene_rect(self
):
171 QtCore
.QPointF(0, 0), QtCore
.QPointF(pixmap
.width(), pixmap
.height())
176 def image_scene_rect(self
):
177 return QtCore
.QRectF(
178 self
.graphics_pixmap
.pos(), QtCore
.QSizeF(self
.pixmap
.size())
181 def resizeEvent(self
, event
):
182 super().resizeEvent(event
)
183 self
.update_scene_rect()
185 self
.fitInView(self
.last_scene_roi
, Qt
.KeepAspectRatio
)
188 def zoomROICentered(self
, p
, zoom_level_delta
):
189 roi
= self
.current_scene_ROI
192 roi_dims
= QtCore
.QPointF(roi
.width(), roi
.height())
195 if zoom_level_delta
> 0:
196 roi_scalef
= 1.0 / self
.zoom_factor
197 elif zoom_level_delta
< 0:
198 roi_scalef
= self
.zoom_factor
200 nroi_dims
= roi_dims
* roi_scalef
201 nroi_dims
.setX(max(nroi_dims
.x(), 1))
202 nroi_dims
.setY(max(nroi_dims
.y(), 1))
205 nroi_dimsh
= nroi_dims
/ 2.0
206 nroi_topleft
= nroi_center
- nroi_dimsh
207 nroi
= QtCore
.QRectF(
208 nroi_topleft
.x(), nroi_topleft
.y(), nroi_dims
.x(), nroi_dims
.y()
210 self
.fitInView(nroi
, Qt
.KeepAspectRatio
)
213 def zoomROITo(self
, p
, zoom_level_delta
):
214 roi
= self
.current_scene_ROI
217 roi_dims
= QtCore
.QPointF(roi
.width(), roi
.height())
218 roi_topleft
= roi
.topLeft()
221 if zoom_level_delta
> 0:
222 roi_scalef
= 1.0 / self
.zoom_factor
223 elif zoom_level_delta
< 0:
224 roi_scalef
= self
.zoom_factor
226 nroi_dims
= roi_dims
* roi_scalef
227 nroi_dims
.setX(max(nroi_dims
.x(), 1.0))
228 nroi_dims
.setY(max(nroi_dims
.y(), 1.0))
230 prel_scaled_x
= (p
.x() - roi_topleft
.x()) / roi_dims
.x()
231 prel_scaled_y
= (p
.y() - roi_topleft
.y()) / roi_dims
.y()
232 nroi_topleft_x
= p
.x() - prel_scaled_x
* nroi_dims
.x()
233 nroi_topleft_y
= p
.y() - prel_scaled_y
* nroi_dims
.y()
235 nroi
= QtCore
.QRectF(
236 nroi_topleft_x
, nroi_topleft_y
, nroi_dims
.x(), nroi_dims
.y()
238 self
.fitInView(nroi
, Qt
.KeepAspectRatio
)
241 def _scene_ROI(self
, geometry
):
242 return QtCore
.QRectF(
243 self
.mapToScene(geometry
.topLeft()), self
.mapToScene(geometry
.bottomRight())
247 def current_scene_ROI(self
):
248 return self
.last_scene_roi
250 def mousePressEvent(self
, event
):
251 super().mousePressEvent(event
)
252 button
= event
.button()
253 modifier
= event
.modifiers()
256 if modifier
== Qt
.ControlModifier
and button
== Qt
.LeftButton
:
257 self
.start_drag
= event
.pos()
260 # initiate/show ROI selection
261 if modifier
== Qt
.ShiftModifier
and button
== Qt
.LeftButton
:
262 self
.start_drag
= event
.pos()
263 if self
.rubberband
is None:
264 self
.rubberband
= QtWidgets
.QRubberBand(
265 QtWidgets
.QRubberBand
.Rectangle
, self
.viewport()
267 self
.rubberband
.setGeometry(QtCore
.QRect(self
.start_drag
, QtCore
.QSize()))
268 self
.rubberband
.show()
270 def mouseMoveEvent(self
, event
):
271 super().mouseMoveEvent(event
)
272 # update selection display
273 if self
.rubberband
is not None:
274 self
.rubberband
.setGeometry(
275 QtCore
.QRect(self
.start_drag
, event
.pos()).normalized()
279 end_drag
= event
.pos()
280 pan_vector
= end_drag
- self
.start_drag
281 scene2view
= self
.transform()
283 sx
= scene2view
.m11()
284 sy
= scene2view
.m22()
285 scene_pan_x
= pan_vector
.x() / sx
286 scene_pan_y
= pan_vector
.y() / sy
287 scene_pan_vector
= QtCore
.QPointF(scene_pan_x
, scene_pan_y
)
288 roi
= self
.current_scene_ROI
289 top_left
= roi
.topLeft()
290 new_top_left
= top_left
- scene_pan_vector
291 scene_rect
= self
.sceneRect()
293 clamp(new_top_left
.x(), scene_rect
.left(), scene_rect
.right())
296 clamp(new_top_left
.y(), scene_rect
.top(), scene_rect
.bottom())
298 nroi
= QtCore
.QRectF(new_top_left
, roi
.size())
299 self
.fitInView(nroi
, Qt
.KeepAspectRatio
)
300 self
.start_drag
= end_drag
303 def mouseReleaseEvent(self
, event
):
304 super().mouseReleaseEvent(event
)
305 # consume rubber band selection
306 if self
.rubberband
is not None:
307 self
.rubberband
.hide()
310 rect
= self
.rubberband
.geometry().normalized()
312 if rect
.width() > 5 and rect
.height() > 5:
314 self
.mapToScene(rect
.topLeft()), self
.mapToScene(rect
.bottomRight())
316 self
.fitInView(roi
, Qt
.KeepAspectRatio
)
318 self
.rubberband
= None
324 def wheelEvent(self
, event
):
325 delta
= qtcompat
.wheel_delta(event
)
328 scene_pos
= self
.mapToScene(event
.pos())
333 self
.zoomROITo(scene_pos
, sign
)
336 self
.update_scene_rect()
337 self
.fitInView(self
.image_scene_rect
, flags
=Qt
.KeepAspectRatio
)
340 # override arbitrary and unwanted margins:
341 # https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
342 def fitInView(self
, rect
, flags
=Qt
.IgnoreAspectRatio
):
343 if self
.scene() is None or not rect
or rect
.isNull():
345 self
.last_scene_roi
= rect
346 unity
= self
.transform().mapRect(QtCore
.QRectF(0.0, 0.0, 1.0, 1.0))
347 self
.scale(1.0 / unity
.width(), 1.0 / unity
.height())
348 viewrect
= self
.viewport().rect()
349 scene_rect
= self
.transform().mapRect(rect
)
350 xratio
= viewrect
.width() / scene_rect
.width()
351 yratio
= viewrect
.height() / scene_rect
.height()
352 if flags
== Qt
.KeepAspectRatio
:
353 xratio
= yratio
= min(xratio
, yratio
)
354 elif flags
== Qt
.KeepAspectRatioByExpanding
:
355 xratio
= yratio
= max(xratio
, yratio
)
356 self
.scale(xratio
, yratio
)
357 self
.centerOn(rect
.center())
360 class AppImageView(ImageView
):
361 def __init__(self
, parent
=None):
362 ImageView
.__init
__(self
, parent
=parent
)
363 self
.main_widget
= None
365 def mousePressEvent(self
, event
):
366 ImageView
.mousePressEvent(self
, event
)
368 def mouseMoveEvent(self
, event
):
369 ImageView
.mouseMoveEvent(self
, event
)
371 scene_pos
= self
.mapToScene(pos
)
372 msg
= 'ui: %d, %d image: %d, %d' % (
378 self
.main_widget
.statusBar().showMessage(msg
)
381 class ImageViewerWindow(QtWidgets
.QMainWindow
):
382 def __init__(self
, image
, input_path
):
383 QtWidgets
.QMainWindow
.__init
__(self
)
385 self
.input_path
= input_path
386 self
.image_view
= AppImageView(parent
=self
)
387 self
.image_view
.main_widget
= self
388 self
.statusBar().showMessage('')
390 padding
= self
.frameGeometry().size() - self
.geometry().size()
391 self
.resize(image
.size() + padding
)
393 central
= QtWidgets
.QWidget(self
)
394 self
.vbox
= QtWidgets
.QVBoxLayout(central
)
395 self
.vbox
.setContentsMargins(0, 0, 0, 0)
396 self
.setCentralWidget(central
)
397 self
.layout().setContentsMargins(0, 0, 0, 0)
399 Expanding
= QtWidgets
.QSizePolicy
.Expanding
400 height_for_width
= self
.image_view
.sizePolicy().hasHeightForWidth()
401 policy
= QtWidgets
.QSizePolicy(Expanding
, Expanding
)
402 policy
.setHorizontalStretch(1)
403 policy
.setVerticalStretch(1)
404 policy
.setHeightForWidth(height_for_width
)
405 self
.image_view
.setSizePolicy(policy
)
407 self
.image_view
.setMouseTracking(True)
408 self
.image_view
.setFocusPolicy(Qt
.NoFocus
)
409 self
.image_view
.setVerticalScrollBarPolicy(Qt
.ScrollBarAlwaysOff
)
410 self
.image_view
.setHorizontalScrollBarPolicy(Qt
.ScrollBarAlwaysOff
)
411 self
.vbox
.addWidget(self
.image_view
)
413 screen
= QtWidgets
.QDesktopWidget().screenGeometry(self
)
414 size
= self
.geometry()
416 (screen
.width() - size
.width()) // 4, (screen
.height() - size
.height()) // 4
420 self
.image_view
.reset()
422 def hideEvent(self
, _event
):
423 QtWidgets
.QMainWindow
.hide(self
)
425 def update_view(self
):
426 self
.image_view
.image
= self
.image
427 self
.setWindowTitle(self
.make_window_title())
429 def make_window_title(self
):
430 return os
.path
.basename(self
.input_path
)
432 def keyPressEvent(self
, event
):
434 global main_loop_type
435 if key
== Qt
.Key_Escape
:
436 if main_loop_type
== 'qt':
437 QtWidgets
.QApplication
.quit()
438 elif main_loop_type
== 'ipython':
441 # IPython.get_ipython().ask_exit()
444 def sigint_handler(*args
):
445 """Handler for the SIGINT signal."""
446 sys
.stderr
.write('\r')
447 QtWidgets
.QApplication
.quit()
451 parser
= argparse
.ArgumentParser(description
='image viewer')
452 parser
.add_argument('image', help='path to the image')
453 opts
= parser
.parse_args()
455 input_image
= opts
.image
456 image
= QtGui
.QImage()
457 image
.load(input_image
)
459 app
= QtWidgets
.QApplication(sys
.argv
)
463 signal
.signal(signal
.SIGINT
, sigint_handler
)
466 window
= ImageViewerWindow(image
, input_image
)
471 if __name__
== '__main__':