core: make getcwd() fail-safe
[git-cola.git] / cola / widgets / imageview.py
blob33d5cb2af55a3d1af68d2ea6dee41295159dbcea
1 # Copyright (c) 2018 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.
30 from __future__ import absolute_import, division, unicode_literals
32 import argparse
33 import os
34 import sys
36 from qtpy import QtCore
37 from qtpy import QtGui
38 from qtpy import QtWidgets
39 from qtpy.QtCore import Qt
40 from qtpy.QtCore import Signal
41 try:
42 import numpy as np
43 have_numpy = True
44 except ImportError:
45 have_numpy = False
47 from .. import qtcompat
49 main_loop_type = 'qt'
52 def clamp(x, lo, hi):
53 return max(min(x, hi), lo)
56 class ImageView(QtWidgets.QGraphicsView):
57 image_changed = Signal()
59 def __init__(self, parent=None):
60 super(ImageView, self).__init__(parent)
62 scene = QtWidgets.QGraphicsScene(self)
63 self.graphics_pixmap = QtWidgets.QGraphicsPixmapItem()
64 scene.addItem(self.graphics_pixmap)
65 self.setScene(scene)
67 self.zoom_factor = 1.125
68 self.rubberband = None
69 self.panning = False
70 self.first_show_occured = False
71 self.last_scene_roi = None
72 self.start_drag = QtCore.QPoint()
73 self.checkerboard = None
75 CHECK_MEDIUM = 8
76 CHECK_GRAY = 0x80
77 CHECK_LIGHT = 0xcc
78 check_pattern = QtGui.QImage(CHECK_MEDIUM * 2, CHECK_MEDIUM * 2,
79 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(CHECK_MEDIUM, CHECK_MEDIUM, CHECK_MEDIUM,
85 CHECK_MEDIUM, color_gray)
86 painter.fillRect(0, CHECK_MEDIUM, CHECK_MEDIUM,
87 CHECK_MEDIUM, color_light)
88 painter.fillRect(CHECK_MEDIUM, 0, CHECK_MEDIUM,
89 CHECK_MEDIUM, color_light)
90 self.check_pattern = check_pattern
91 self.check_brush = QtGui.QBrush(check_pattern)
93 def load(self, filename):
94 image = QtGui.QImage()
95 image.load(filename)
96 ok = not image.isNull()
97 if ok:
98 self.pixmap = image
99 return ok
101 @property
102 def pixmap(self):
103 return self.graphics_pixmap.pixmap()
105 @pixmap.setter
106 def pixmap(self, image, image_format=None):
107 pixmap = None
108 if have_numpy and isinstance(image, np.ndarray):
109 if image.ndim == 3:
110 if image.shape[2] == 3:
111 if image_format is None:
112 image_format = QtGui.QImage.Format_RGB888
113 q_image = QtGui.QImage(
114 image.data, image.shape[1],
115 image.shape[0], image_format)
116 pixmap = QtGui.QPixmap.fromImage(q_image)
117 elif image.shape[2] == 4:
118 if image_format is None:
119 image_format = QtGui.QImage.Format_RGB32
120 q_image = QtGui.QImage(
121 image.data, image.shape[1],
122 image.shape[0], image_format)
123 pixmap = QtGui.QPixmap.fromImage(q_image)
124 else:
125 raise TypeError(image)
126 elif image.ndim == 2:
127 if image_format is None:
128 image_format = QtGui.QImage.Format_RGB888
129 q_image = QtGui.QImage(image.data, image.shape[1],
130 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(pixmap.width(), pixmap.height(),
144 QtGui.QImage.Format_ARGB32)
145 self.checkerboard = checkerboard
146 painter = QtGui.QPainter(checkerboard)
147 painter.fillRect(checkerboard.rect(), self.check_brush)
148 painter.drawPixmap(0, 0, pixmap)
149 pixmap = QtGui.QPixmap.fromImage(checkerboard)
151 self.graphics_pixmap.setPixmap(pixmap)
152 self.update_scene_rect()
153 self.fitInView(self.image_scene_rect, flags=Qt.KeepAspectRatio)
154 self.graphics_pixmap.update()
155 self.image_changed.emit()
157 # image property alias
158 @property
159 def image(self):
160 return self.pixmap
162 @image.setter
163 def image(self, image):
164 self.pixmap = image
166 def update_scene_rect(self):
167 pixmap = self.pixmap
168 self.setSceneRect(QtCore.QRectF(
169 QtCore.QPointF(0, 0),
170 QtCore.QPointF(pixmap.width(), pixmap.height())))
172 @property
173 def image_scene_rect(self):
174 return QtCore.QRectF(
175 self.graphics_pixmap.pos(), QtCore.QSizeF(self.pixmap.size()))
177 def resizeEvent(self, event):
178 super(ImageView, self).resizeEvent(event)
179 self.update_scene_rect()
180 event.accept()
181 self.fitInView(self.last_scene_roi, Qt.KeepAspectRatio)
182 self.update()
184 def zoomROICentered(self, p, zoom_level_delta):
185 roi = self.current_scene_ROI
186 roi_dims = QtCore.QPointF(roi.width(), roi.height())
187 roi_scalef = 1
189 if zoom_level_delta > 0:
190 roi_scalef = 1.0/self.zoom_factor
191 elif zoom_level_delta < 0:
192 roi_scalef = self.zoom_factor
194 nroi_dims = roi_dims * roi_scalef
195 nroi_dims.setX(max(nroi_dims.x(), 1))
196 nroi_dims.setY(max(nroi_dims.y(), 1))
198 nroi_center = p
199 nroi_dimsh = nroi_dims / 2.0
200 nroi_topleft = nroi_center - nroi_dimsh
201 nroi = QtCore.QRectF(
202 nroi_topleft.x(), nroi_topleft.y(),
203 nroi_dims.x(), nroi_dims.y())
204 self.fitInView(nroi, Qt.KeepAspectRatio)
205 self.update()
207 def zoomROITo(self, p, zoom_level_delta):
208 roi = self.current_scene_ROI
209 roi_dims = QtCore.QPointF(roi.width(), roi.height())
210 roi_topleft = roi.topLeft()
211 roi_scalef = 1.0
213 if zoom_level_delta > 0:
214 roi_scalef = 1.0 / self.zoom_factor
215 elif zoom_level_delta < 0:
216 roi_scalef = self.zoom_factor
218 nroi_dims = roi_dims * roi_scalef
219 nroi_dims.setX(max(nroi_dims.x(), 1.0))
220 nroi_dims.setY(max(nroi_dims.y(), 1.0))
222 prel_scaled_x = (p.x() - roi_topleft.x()) / roi_dims.x()
223 prel_scaled_y = (p.y() - roi_topleft.y()) / roi_dims.y()
224 nroi_topleft_x = p.x() - prel_scaled_x * nroi_dims.x()
225 nroi_topleft_y = p.y() - prel_scaled_y * nroi_dims.y()
227 nroi = QtCore.QRectF(
228 nroi_topleft_x, nroi_topleft_y,
229 nroi_dims.x(), nroi_dims.y())
230 self.fitInView(nroi, Qt.KeepAspectRatio)
231 self.update()
233 def _scene_ROI(self, geometry):
234 return QtCore.QRectF(
235 self.mapToScene(geometry.topLeft()),
236 self.mapToScene(geometry.bottomRight()))
238 @property
239 def current_scene_ROI(self):
240 return self.last_scene_roi
242 def mousePressEvent(self, event):
243 super(ImageView, self).mousePressEvent(event)
244 button = event.button()
245 modifier = event.modifiers()
247 # pan
248 if modifier == Qt.ControlModifier and button == Qt.LeftButton:
249 self.start_drag = event.pos()
250 self.panning = True
252 # initiate/show ROI selection
253 if modifier == Qt.ShiftModifier and button == Qt.LeftButton:
254 self.start_drag = event.pos()
255 if self.rubberband is None:
256 self.rubberband = QtWidgets.QRubberBand(
257 QtWidgets.QRubberBand.Rectangle, self.viewport())
258 self.rubberband.setGeometry(
259 QtCore.QRect(self.start_drag, QtCore.QSize()))
260 self.rubberband.show()
262 def mouseMoveEvent(self, event):
263 super(ImageView, self).mouseMoveEvent(event)
264 # update selection display
265 if self.rubberband is not None:
266 self.rubberband.setGeometry(
267 QtCore.QRect(self.start_drag, event.pos()).normalized())
269 if self.panning:
270 end_drag = event.pos()
271 pan_vector = end_drag - self.start_drag
272 scene2view = self.transform()
273 # skip shear
274 sx = scene2view.m11()
275 sy = scene2view.m22()
276 scene_pan_x = pan_vector.x() / sx
277 scene_pan_y = pan_vector.y() / sy
278 scene_pan_vector = QtCore.QPointF(scene_pan_x, scene_pan_y)
279 roi = self.current_scene_ROI
280 top_left = roi.topLeft()
281 new_top_left = top_left - scene_pan_vector
282 scene_rect = self.sceneRect()
283 new_top_left.setX(clamp(new_top_left.x(),
284 scene_rect.left(), scene_rect.right()))
285 new_top_left.setY(clamp(new_top_left.y(),
286 scene_rect.top(), scene_rect.bottom()))
287 nroi = QtCore.QRectF(new_top_left, roi.size())
288 self.fitInView(nroi, Qt.KeepAspectRatio)
289 self.start_drag = end_drag
290 self.update()
292 def mouseReleaseEvent(self, event):
293 super(ImageView, self).mouseReleaseEvent(event)
294 # consume rubber band selection
295 if self.rubberband is not None:
296 self.rubberband.hide()
298 # set view to ROI
299 rect = self.rubberband.geometry().normalized()
301 if rect.width() > 5 and rect.height() > 5:
302 roi = QtCore.QRectF(
303 self.mapToScene(rect.topLeft()),
304 self.mapToScene(rect.bottomRight()))
305 self.fitInView(roi, Qt.KeepAspectRatio)
307 self.rubberband = None
309 if self.panning:
310 self.panning = False
311 self.update()
313 def wheelEvent(self, event):
314 delta = qtcompat.wheel_delta(event)
315 # adjust zoom
316 if abs(delta) > 0:
317 scene_pos = self.mapToScene(event.pos())
318 if delta >= 0:
319 sign = 1
320 else:
321 sign = -1
322 self.zoomROITo(scene_pos, sign)
324 def reset(self):
325 self.update_scene_rect()
326 self.fitInView(self.image_scene_rect, flags=Qt.KeepAspectRatio)
327 self.update()
329 # override arbitrary and unwanted margins:
330 # https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources
331 def fitInView(self, rect, flags=Qt.IgnoreAspectRatio):
332 if self.scene() is None or not rect or rect.isNull():
333 return
334 self.last_scene_roi = rect
335 unity = self.transform().mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
336 self.scale(1.0/unity.width(), 1.0/unity.height())
337 viewrect = self.viewport().rect()
338 scene_rect = self.transform().mapRect(rect)
339 xratio = viewrect.width() / scene_rect.width()
340 yratio = viewrect.height() / scene_rect.height()
341 if flags == Qt.KeepAspectRatio:
342 xratio = yratio = min(xratio, yratio)
343 elif flags == Qt.KeepAspectRatioByExpanding:
344 xratio = yratio = max(xratio, yratio)
345 self.scale(xratio, yratio)
346 self.centerOn(rect.center())
349 class AppImageView(ImageView):
351 def __init__(self, parent=None):
352 ImageView.__init__(self, parent=parent)
353 self.main_widget = None
355 def mousePressEvent(self, event):
356 ImageView.mousePressEvent(self, event)
358 def mouseMoveEvent(self, event):
359 ImageView.mouseMoveEvent(self, event)
360 pos = event.pos()
361 scene_pos = self.mapToScene(pos)
362 msg = ('ui: %d, %d image: %d, %d'
363 % (pos.y(), pos.x(), int(scene_pos.y()), int(scene_pos.x())))
364 self.main_widget.statusBar().showMessage(msg)
367 class ImageViewerWindow(QtWidgets.QMainWindow):
369 def __init__(self, image, input_path):
370 QtWidgets.QMainWindow.__init__(self)
371 self.image = image
372 self.input_path = input_path
373 self.image_view = AppImageView(parent=self)
374 self.image_view.main_widget = self
375 self.statusBar().showMessage('')
377 padding = self.frameGeometry().size() - self.geometry().size()
378 self.resize(image.size() + padding)
380 central = QtWidgets.QWidget(self)
381 self.vbox = QtWidgets.QVBoxLayout(central)
382 self.vbox.setContentsMargins(0, 0, 0, 0)
383 self.setCentralWidget(central)
384 self.layout().setContentsMargins(0, 0, 0, 0)
386 Expanding = QtWidgets.QSizePolicy.Expanding
387 height_for_width = self.image_view.sizePolicy().hasHeightForWidth()
388 policy = QtWidgets.QSizePolicy(Expanding, Expanding)
389 policy.setHorizontalStretch(1)
390 policy.setVerticalStretch(1)
391 policy.setHeightForWidth(height_for_width)
392 self.image_view.setSizePolicy(policy)
394 self.image_view.setMouseTracking(True)
395 self.image_view.setFocusPolicy(Qt.NoFocus)
396 self.image_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
397 self.image_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
398 self.vbox.addWidget(self.image_view)
400 screen = QtWidgets.QDesktopWidget().screenGeometry(self)
401 size = self.geometry()
402 self.move((screen.width() - size.width()) // 4,
403 (screen.height() - size.height()) // 4)
405 self.update_view()
406 self.image_view.reset()
408 def hideEvent(self, _event):
409 QtWidgets.QMainWindow.hide(self)
411 def update_view(self):
412 self.image_view.image = self.image
413 self.setWindowTitle(self.make_window_title())
415 def make_window_title(self):
416 return os.path.basename(self.input_path)
418 def keyPressEvent(self, event):
419 key = event.key()
420 global main_loop_type # pylint: disable=global-statement
421 if key == Qt.Key_Escape:
422 if main_loop_type == 'qt':
423 QtWidgets.QApplication.quit()
424 elif main_loop_type == 'ipython':
425 self.hide()
426 # import IPython
427 # IPython.get_ipython().ask_exit()
430 # pylint: disable=unused-argument
431 def sigint_handler(*args):
432 """Handler for the SIGINT signal."""
433 sys.stderr.write('\r')
434 QtWidgets.QApplication.quit()
437 def main():
438 parser = argparse.ArgumentParser(description='image viewer')
439 parser.add_argument('image', help='path to the image')
440 opts = parser.parse_args()
442 input_image = opts.image
443 image = QtGui.QImage()
444 image.load(input_image)
446 app = QtWidgets.QApplication(sys.argv)
447 try:
448 import signal
449 signal.signal(signal.SIGINT, sigint_handler)
450 except ImportError:
451 pass
452 window = ImageViewerWindow(image, input_image)
453 window.show()
454 app.exec_()
457 if __name__ == '__main__':
458 main()