Merge pull request #1387 from davvid/remote-dialog
[git-cola.git] / cola / widgets / imageview.py
blobb18333dede7936963b9116c9ecf085518f1a36bd
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
28 # SOFTWARE.
29 import argparse
30 import os
31 import sys
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
39 try:
40 import numpy as np
42 have_numpy = True
43 except ImportError:
44 have_numpy = False
46 from .. import qtcompat
48 main_loop_type = 'qt'
51 def clamp(x, lo, hi):
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)
64 self.setScene(scene)
66 self.zoom_factor = 1.125
67 self.rubberband = None
68 self.panning = False
69 self.first_show_occured = False
70 self.last_scene_roi = None
71 self.start_drag = QtCore.QPoint()
72 self.checkerboard = None
74 CHECK_MEDIUM = 8
75 CHECK_GRAY = 0x80
76 CHECK_LIGHT = 0xCC
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)
84 painter.fillRect(
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()
94 image.load(filename)
95 ok = not image.isNull()
96 if ok:
97 self.pixmap = image
98 return ok
100 @property
101 def pixmap(self):
102 return self.graphics_pixmap.pixmap()
104 @pixmap.setter
105 def pixmap(self, image, image_format=None):
106 pixmap = None
107 if have_numpy and isinstance(image, np.ndarray):
108 if image.ndim == 3:
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)
123 else:
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)
132 else:
133 raise ValueError(image)
135 elif isinstance(image, QtGui.QImage):
136 pixmap = QtGui.QPixmap.fromImage(image)
137 elif isinstance(image, QtGui.QPixmap):
138 pixmap = image
139 else:
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
159 @property
160 def image(self):
161 return self.pixmap
163 @image.setter
164 def image(self, image):
165 self.pixmap = image
167 def update_scene_rect(self):
168 pixmap = self.pixmap
169 self.setSceneRect(
170 QtCore.QRectF(
171 QtCore.QPointF(0, 0), QtCore.QPointF(pixmap.width(), pixmap.height())
175 @property
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()
184 event.accept()
185 self.fitInView(self.last_scene_roi, Qt.KeepAspectRatio)
186 self.update()
188 def zoomROICentered(self, p, zoom_level_delta):
189 roi = self.current_scene_ROI
190 if not roi:
191 return
192 roi_dims = QtCore.QPointF(roi.width(), roi.height())
193 roi_scalef = 1
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))
204 nroi_center = p
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)
211 self.update()
213 def zoomROITo(self, p, zoom_level_delta):
214 roi = self.current_scene_ROI
215 if not roi:
216 return
217 roi_dims = QtCore.QPointF(roi.width(), roi.height())
218 roi_topleft = roi.topLeft()
219 roi_scalef = 1.0
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)
239 self.update()
241 def _scene_ROI(self, geometry):
242 return QtCore.QRectF(
243 self.mapToScene(geometry.topLeft()), self.mapToScene(geometry.bottomRight())
246 @property
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()
255 # pan
256 if modifier == Qt.ControlModifier and button == Qt.LeftButton:
257 self.start_drag = event.pos()
258 self.panning = True
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()
278 if self.panning:
279 end_drag = event.pos()
280 pan_vector = end_drag - self.start_drag
281 scene2view = self.transform()
282 # skip shear
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()
292 new_top_left.setX(
293 clamp(new_top_left.x(), scene_rect.left(), scene_rect.right())
295 new_top_left.setY(
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
301 self.update()
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()
309 # set view to ROI
310 rect = self.rubberband.geometry().normalized()
312 if rect.width() > 5 and rect.height() > 5:
313 roi = QtCore.QRectF(
314 self.mapToScene(rect.topLeft()), self.mapToScene(rect.bottomRight())
316 self.fitInView(roi, Qt.KeepAspectRatio)
318 self.rubberband = None
320 if self.panning:
321 self.panning = False
322 self.update()
324 def wheelEvent(self, event):
325 delta = qtcompat.wheel_delta(event)
326 # adjust zoom
327 if abs(delta) > 0:
328 scene_pos = self.mapToScene(event.pos())
329 if delta >= 0:
330 sign = 1
331 else:
332 sign = -1
333 self.zoomROITo(scene_pos, sign)
335 def reset(self):
336 self.update_scene_rect()
337 self.fitInView(self.image_scene_rect, flags=Qt.KeepAspectRatio)
338 self.update()
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():
344 return
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)
370 pos = event.pos()
371 scene_pos = self.mapToScene(pos)
372 msg = 'ui: %d, %d image: %d, %d' % (
373 pos.y(),
374 pos.x(),
375 int(scene_pos.y()),
376 int(scene_pos.x()),
378 self.main_widget.statusBar().showMessage(msg)
381 class ImageViewerWindow(QtWidgets.QMainWindow):
382 def __init__(self, image, input_path):
383 QtWidgets.QMainWindow.__init__(self)
384 self.image = image
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()
415 self.move(
416 (screen.width() - size.width()) // 4, (screen.height() - size.height()) // 4
419 self.update_view()
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):
433 key = event.key()
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':
439 self.hide()
440 # import 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()
450 def main():
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)
460 try:
461 import signal
463 signal.signal(signal.SIGINT, sigint_handler)
464 except ImportError:
465 pass
466 window = ImageViewerWindow(image, input_image)
467 window.show()
468 app.exec_()
471 if __name__ == '__main__':
472 main()