dupeguru/qt/pe/image_viewer.py

596 lines
20 KiB
Python

# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import QObject, Qt, QSize, QRectF, QPointF, pyqtSlot, pyqtSignal, QEvent
from PyQt5.QtGui import QPixmap, QPainter, QPalette
from PyQt5.QtWidgets import ( QLabel, QSizePolicy, QWidget, QScrollArea,
QScrollBar, QApplication, QAbstractScrollArea )
#TODO: fix panning while zoomed-in
#TODO: fix scroll area not showing up
#TODO: add keyboard shortcuts
class BaseController(QObject):
"""Base proxy interface to keep image viewers synchronized.
Relays function calls. Singleton. """
def __init__(self, selectedViewer, referenceViewer, parent):
super().__init__()
self.selectedViewer = selectedViewer
self.referenceViewer = referenceViewer
self.selectedPixmap = QPixmap()
self.referencePixmap = QPixmap()
self.scaledSelectedPixmap = QPixmap()
self.scaledReferencePixmap = QPixmap()
self.scaleFactor = 1.0
self.bestFit = True
self.parent = parent #needed to change buttons' states
self.selectedViewer.controller = self
self.referenceViewer.controller = self
self._setupConnections()
def _setupConnections(self): #virtual
pass
def update(self, ref, dupe):
self.resetState()
self.selectedPixmap = QPixmap(str(dupe.path))
if ref is dupe: # currently selected file is the actual reference file
self.referencePixmap = QPixmap()
self.scaledReferencePixmap = QPixmap()
self.parent.buttonImgSwap.setEnabled(False)
# disable the blank widget.
self.referenceViewer.setPixmap(self.referencePixmap)
else:
self.referencePixmap = QPixmap(str(ref.path))
self.parent.buttonImgSwap.setEnabled(True)
# self.enable_widget(self.referenceViewer)
# self.update_selected_widget()
# self.update_reference_widget()
self._updateImages()
def _updateImages(self):
target_size = None
if not self.selectedPixmap.isNull():
target_size = self.selectedViewer.size()
if not self.bestFit:
# zoomed in state, expand
self.scaledSelectedPixmap = self.selectedPixmap.scaled(
target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
else:
# best fit, keep ratio always
self.scaledSelectedPixmap = self.selectedPixmap.scaled(
target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
self.selectedViewer.setPixmap(self.scaledSelectedPixmap)
if not self.referencePixmap.isNull():
# the selectedImage viewer widget sometimes ends up being bigger
# than the referenceImage viewer, which distorts by one pixel the
# scaled down pixmap for the reference, hence we'll reuse its size here.
# target_size = self.selectedViewer.size()
if not self.bestFit:
self.scaledReferencePixmap = self.referencePixmap.scaled(
target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
else:
self.scaledReferencePixmap = self.referencePixmap.scaled(
target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
self.referenceViewer.setPixmap(self.scaledReferencePixmap)
@pyqtSlot(float)
def scaleImages(self, factor):
self.scaleFactor *= factor
print(f'Controller scaleFactor = \
{self.scaleFactor} (+factor {factor})')
self.parent.buttonZoomIn.setEnabled(self.scaleFactor < 16.0)
self.parent.buttonZoomOut.setEnabled(self.scaleFactor > 1.0)
self.parent.buttonBestFit.setEnabled(self.bestFit is False)
self.parent.buttonNormalSize.setEnabled(self.scaleFactor != 1.0)
def sefCenter(self):
#FIXME need specialization?
self.selectedViewer.setCenter()
self.referenceViewer.setCenter()
def resetState(self):
self.selectedPixmap = QPixmap()
self.scaledSelectedPixmap = QPixmap()
self.referencePixmap = QPixmap()
self.scaledReferencePixmap = QPixmap()
self.setBestFit(True)
self.scaleFactor = 1.0
self.setCenter()
self.parent.buttonZoomIn.setEnabled(False)
self.parent.buttonZoomOut.setEnabled(False)
self.parent.buttonBestFit.setEnabled(False) # active mode by default
self.parent.buttonNormalSize.setEnabled(True)
def clear_all(self):
"""No item from the model, disable and clear everything."""
self.resetState()
self.selectedViewer.setPixmap(QPixmap())
self.selectedViewer.setDisabled(True)
self.referenceViewer.setPixmap(QPixmap())
self.referenceViewer.setDisabled(True)
self.parent.buttonImgSwap.setDisabled(True)
self.parent.buttonNormalSize.setDisabled(True)
def swapImages(self):
if self.bestFit:
self.selectedViewer.setPixmap(self.scaledReferencePixmap)
self.referenceViewer.setPixmap(self.scaledSelectedPixmap)
else:
self.selectedViewer.setPixmap(self.referencePixmap)
self.referenceViewer.setPixmap(self.selectedPixmap)
def deswapImages(self):
if self.bestFit:
self.selectedViewer.setPixmap(self.scaledSelectedPixmap)
self.referenceViewer.setPixmap(self.scaledReferencePixmap)
else:
self.selectedViewer.setPixmap(self.selectedPixmap)
self.referenceViewer.setPixmap(self.referencePixmap)
def zoomBestFit(self):
self.setBestFit(True)
self.scaleFactor = 1.0
self.parent.buttonBestFit.setEnabled(False)
self.parent.buttonZoomOut.setEnabled(False)
self.parent.buttonZoomIn.setEnabled(False)
self.parent.buttonNormalSize.setEnabled(True)
def zoomNormalSize(self):
self.setBestFit(False)
self.scaleFactor = 1.0
self.selectedViewer.setPixmap(self.selectedPixmap)
self.referenceViewer.setPixmap(self.referencePixmap)
# self.update_selected_widget()
# self.update_reference_widget()
self.selectedViewer.pixmapReset()
self.referenceViewer.pixmapReset()
self.parent.buttonNormalSize.setEnabled(False)
self.parent.buttonZoomIn.setEnabled(True)
self.parent.buttonZoomOut.setEnabled(True)
self.parent.buttonBestFit.setEnabled(True)
def setBestFit(self, value):
self.bestFit = value
self.selectedViewer.bestFit = value
self.referenceViewer.bestFit = value
def setCenter(self):
self.selectedViewer.setCenter()
self.referenceViewer.setCenter()
# def update_selected_widget(self):
# print("update_selected_widget()")
# if not self.selectedPixmap.isNull():
# self.enable_widget(self.selectedViewer)
# self.connect_signal(self.selectedViewer, self.referenceViewer)
# else:
# self.disable_widget(self.selectedViewer)
# self.disconnect_signal(self.referenceViewer)
# def update_reference_widget(self):
# print("update_reference_widget()")
# if not self.referencePixmap.isNull():
# self.enable_widget(self.referenceViewer)
# self.connect_signal(self.referenceViewer, self.selectedViewer)
# else:
# self.disable_widget(self.referenceViewer)
# self.disconnect_signal(self.selectedViewer)
# def enable_widget(self, widget):
# if not widget.isEnabled():
# widget.setEnabled(True)
# def disable_widget(self, widget):
# """Disables this widget and prevents receiving signals from other_widget."""
# print(f"disable_widget({widget})")
# widget.setPixmap(QPixmap())
# widget.setDisabled(True)
# def connect_signal(self, widget, other_widget):
# """We want this widget to send its signal to the other_widget."""
# print(f"connect_signal({widget}, {other_widget})")
# if widget.connection is None:
# if other_widget.isEnabled():
# widget.connection = widget.mouseDragged.connect(other_widget.slot_paint_event)
# print(f"Connected signal from {widget} to slot of {other_widget}")
# def disconnect_signal(self, other_widget):
# """We don't want this widget to send its signal anymore to the other_widget."""
# print(f"disconnect_signal({other_widget}")
# if other_widget.connection:
# other_widget.mouseDragged.disconnect()
# other_widget.connection = None
# print(f"Disconnected signal from {other_widget}")
class QWidgetImageViewerController(BaseController):
"""Specialized version for QWidget-based viewers"""
def __init__(self, selectedViewer, referenceViewer, parent):
super().__init__(selectedViewer, referenceViewer, parent)
# self._setupConnections()
def _setupConnections(self):
# self.selectedViewer._wheelConnection = \
# self.selectedViewer.mouseWheeled.connect(self.scaleImages)
# self.referenceViewer._wheelConnection = \
# self.referenceViewer.mouseWheeled.connect(self.scaleImages)
self.selectedViewer.connect_signals()
self.referenceViewer.connect_signals()
def scale(self, factor):
self.selectedViewer.scale(factor)
self.referenceViewer.scale(factor)
@pyqtSlot(float)
def scaleImages(self, factor):
super().scaleImages(factor)
# we scale the Qwidget itself in this case
self.selectedViewer.scale(self.scaleFactor)
self.referenceViewer.scale(self.scaleFactor)
def scale_to_bestfit(self):
self.scale(1.0)
super().setCenter()
super()._updateImages()
@pyqtSlot(QPointF)
def slot_paint_event(self, delta):
if self.sender() is self.referenceViewer:
self.selectedViewer.slot_paint_event(delta)
else:
self.referenceViewer.slot_paint_event(delta)
class QLabelImageViewerController(BaseController):
"""Specialized version fro QLabel-based viewers"""
def __init__(self, selectedViewer, referenceViewer, parent):
super().__init__(selectedViewer, referenceViewer, parent)
def scale(self, factor):
pass #FIXME
@pyqtSlot(float)
def scaleImages(self, factor):
super().scaleImages(factor)
# we scale the member Qlable in this case
self.selectedViewer.scale(self.scaleFactor)
self.referenceViewer.scale(self.scaleFactor)
class GraphicsViewController(BaseController):
"""Specialized version fro QGraphicsView-based viewers"""
#TODO
pass
class QWidgetImageViewer(QWidget):
"""Displays image and allows manipulations."""
mouseDragged = pyqtSignal(QPointF)
mouseWheeled = pyqtSignal(float)
def __init__(self, parent, name=""):
super().__init__(parent)
self._app = QApplication
self._pixmap = QPixmap()
self._rect = QRectF()
self._reference = QPointF()
self._delta = QPointF()
self._scaleFactor = 1.0
self._drag = False
self._dragConnection = None
self._wheelConnection = None
self._instance_name = name
self.bestFit = True
self.controller = None
def __repr__(self):
return f'{self._instance_name}'
def paintEvent(self, event):
painter = QPainter(self)
painter.translate(self.rect().center())
painter.scale(self._scaleFactor, self._scaleFactor)
painter.translate(self._delta)
painter.drawPixmap(self._rect.topLeft(), self._pixmap)
# print(f"{self} paintEvent delta={self._delta}")
def setCenter(self):
""" Resets origin """
self._delta = QPointF()
self._scaleFactor = 1.0
self.scale(self._scaleFactor)
self.update()
def changeEvent(self, event):
if event.type() == QEvent.EnabledChange:
print(f"{self} is now {'enabled' if self.isEnabled() else 'disabled'}")
if self.isEnabled():
self.connect_signals()
return
self.disconnect_signals()
def mousePressEvent(self, event):
if self.bestFit:
event.ignore()
return
if event.buttons() == Qt.LeftButton:
self._drag = True
self._reference = event.pos()
self._app.setOverrideCursor(Qt.ClosedHandCursor)
self.setMouseTracking(True)
def mouseMoveEvent(self, event):
if self.bestFit:
event.ignore()
return
self._delta += (event.pos() - self._reference) * 1.0/self._scaleFactor
self._reference = event.pos()
if self._drag:
self.mouseDragged.emit(self._delta)
self.update()
def mouseReleaseEvent(self, event):
if self.bestFit:
event.ignore()
return
if event.buttons() == Qt.LeftButton:
drag = False
self._app.restoreOverrideCursor()
self.setMouseTracking(False)
def wheelEvent(self, event):
if self.bestFit:
event.ignore()
return
if event.angleDelta().y() > 0:
self.mouseWheeled.emit(1.25) # zoom-in
else:
self.mouseWheeled.emit(0.8) # zoom-out
def setPixmap(self, pixmap):
if pixmap.isNull():
if not self._pixmap.isNull():
self._pixmap = pixmap
self.disconnect_signals()
self.update()
return
elif not self.isEnabled():
self.connect_signals()
self.setEnabled(True)
self._pixmap = pixmap
self._rect = self._pixmap.rect()
self._rect.translate(-self._rect.center())
self.update()
def isActive(self):
return True if not self.pixmap.isNull() else False
def disconnect_signals(self):
if self._dragConnection:
self.mouseDragged.disconnect()
self._dragConnection = None
if self._wheelConnection:
self.mouseWheeled.disconnect()
self._wheelConnection = None
def connect_signals(self):
if not self._dragConnection:
self._dragConnection = self.mouseDragged.connect(
self.controller.slot_paint_event)
if not self._wheelConnection:
self._wheelConnection = self.mouseWheeled.connect(
self.controller.scaleImages)
def scale(self, factor):
self._scaleFactor = factor
self.update()
def sizeHint(self):
return QSize(400, 400)
@pyqtSlot()
def pixmapReset(self):
"""Called when the pixmap is set back to original size."""
self._scaleFactor = 1.0
self.update()
@pyqtSlot(QPointF)
def slot_paint_event(self, delta):
self._delta = delta
self.update()
# print(f"{self} received drag signal from {self.sender()}")
class ScrollAreaImageViewer(QScrollArea):
"""Version with Qlabel for testing"""
mouseDragged = pyqtSignal(QPointF)
def __init__(self, parent, name=""):
super().__init__(parent)
self._parent = parent
self._app = QApplication
self._pixmap = QPixmap()
self._rect = QRectF()
self._reference = QPointF()
self._delta = QPointF()
self._scaleFactor = 1.0
self._drag = False
self.connection = None # signal bound to a slot
self._instance_name = name
self.controller = None
self.label = QLabel()
sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.label.sizePolicy().hasHeightForWidth())
self.label.setBackgroundRole(QPalette.Base)
self.label.setSizePolicy(sizePolicy)
self.label.setAlignment(Qt.AlignCenter)
self.label.setScaledContents(True)
self.scrollarea = QScrollArea(self)
self.setBackgroundRole(QPalette.Dark)
self.setWidgetResizable(True)
self.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents)
# self.scrollarea.viewport().setAttribute(Qt.WA_StaticContents)
self.setWidget(self.label)
self.setVisible(True)
def __repr__(self):
return f'{self._instance_name}'
def paintEvent(self, event):
painter = QPainter(self)
painter.translate(self.rect().center())
painter.scale(self._scaleFactor, self._scaleFactor)
painter.translate(self._delta)
painter.drawPixmap(self._rect.topLeft(), self._pixmap)
# print(f"{self} paintEvent delta={self._delta}")
def setCenter(self):
""" Resets origin """
self._delta = QPointF()
self._scaleFactor = 1.0
self.scale(self._scaleFactor)
self.update()
def changeEvent(self, event):
if event.type() == QEvent.EnabledChange:
print(f"{self} is now {'enabled' if self.isEnabled() else 'disabled'}")
def mousePressEvent(self, event):
if self._parent.bestFit:
event.ignore()
return
if event.buttons() == Qt.LeftButton:
self._drag = True
self._reference = event.pos()
self._app.setOverrideCursor(Qt.ClosedHandCursor)
self.setMouseTracking(True)
def mouseMoveEvent(self, event):
if self._parent.bestFit:
event.ignore()
return
self._delta += (event.pos() - self._reference) * 1.0/self._scaleFactor
self._reference = event.pos()
if self._drag:
self.mouseDragged.emit(self._delta)
self.update()
def mouseReleaseEvent(self, event):
if self._parent.bestFit:
event.ignore()
return
if event.buttons() == Qt.LeftButton:
drag = False
self._app.restoreOverrideCursor()
self.setMouseTracking(False)
def wheelEvent(self, event):
if self._parent.bestFit:
event.ignore()
return
if event.angleDelta().y() > 0:
self._parent.zoomIn()
else:
self._parent.zoomOut()
def setPixmap(self, pixmap):
#FIXME refactored
# if pixmap.isNull():
# if not self._pixmap.isNull():
# self._pixmap = pixmap
# self.update()
# return
# elif not self.isEnabled():
# self.setEnabled(True)
# self._pixmap = pixmap
self.label.setPixmap(pixmap)
self._rect = self._pixmap.rect()
self._rect.translate(-self._rect.center())
self.update()
def scale(self, factor):
self._scaleFactor = factor
self.label.resize(self._scaleFactor * self.label.pixmap().size())
self.adjustScrollBar(self.scrollarea.horizontalScrollBar(), factor)
self.adjustScrollBar(self.scrollarea.verticalScrollBar(), factor)
self.update()
def adjustScrollBar(self, scrollBar, factor):
scrollBar.setValue(int(factor * scrollBar.value() + ((factor - 1) * scrollBar.pageStep()/2)))
def sizeHint(self):
return QSize(400, 400)
@pyqtSlot()
def pixmapReset(self):
"""Called when the pixmap is set back to original size."""
self._scaleFactor = 1.0
self.update()
@pyqtSlot(QPointF)
def slot_paint_event(self, delta):
self._delta = delta
self.update()
print(f"{self} received signal from {self.sender()}")
from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem
class SceneImageViewer(QGraphicsView):
"""Re-Implementation test"""
def __init__(self, parent):
super().__init__(parent)
self._scene = QGraphicsScene()
self._item = QGraphicsPixmapItem()
self.setScene(_scene)
self._scene.addItem(self.item)
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setResizeAnchor(QGraphicsView.AnchorViewCenter)
def setPixmap(self, pixmap):
self._item.setPixmap(pixmap)
offset = -QRectF(pixmap.rect()).center()
self._item.setOffset(offset)
self.setSceneRect(offset.x()*4, offset.y()*4, -offset.x()*8, -offset.y()*8)
self.translate(1, 1)
def scale(self, factor):
self.scale(factor, factor)
def sizeHint():
return QSize(400, 400)