2

I am trying to piece together PyQt5 based image viewer Python code from various sources and extend capability to crop regions of interest (ROI) within loaded images. The issue is that the mapped coordinates and mouse clicks consider scroll bar and menu bar when determining pixel locations. Following is the code that loads image and provide bounding box capability, but I cannot seem to draw/crop boxes accurately due to the offset.

from PyQt5.QtCore import QDir, Qt
from PyQt5.QtGui import QImage, QPainter, QPalette, QPixmap
from PyQt5.QtWidgets import (QAction, QApplication, QFileDialog, QLabel,
        QMainWindow, QMenu, QMessageBox, QScrollArea, QSizePolicy)
from PyQt5.QtPrintSupport import QPrintDialog, QPrinter


class ImageViewer(QMainWindow):
    def __init__(self):
        super(ImageViewer, self).__init__()

        self.printer = QPrinter()
        self.scaleFactor = 0.0

        self.imageLabel = QLabel()
        self.imageLabel.setBackgroundRole(QPalette.Base)
        self.imageLabel.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
        self.imageLabel.setScaledContents(True)

        self.scrollArea = QScrollArea()
        self.scrollArea.setBackgroundRole(QPalette.Dark)
        self.scrollArea.setWidget(self.imageLabel)
        self.setCentralWidget(self.scrollArea)

        self.createActions()
        self.createMenus()

        self.setWindowTitle("Image Viewer")
        self.resize(500, 400)

    def open(self):
        fileName, _ = QFileDialog.getOpenFileName(self, "Open File",
                QDir.currentPath())
        if fileName:
            image = QImage(fileName)
            if image.isNull():
                QMessageBox.information(self, "Image Viewer",
                        "Cannot load %s." % fileName)
                return

            self.imageLabel.setPixmap(QPixmap.fromImage(image))
            self.scaleFactor = 1.0

            self.printAct.setEnabled(True)
            self.fitToWindowAct.setEnabled(True)
            self.updateActions()

            if not self.fitToWindowAct.isChecked():
                self.imageLabel.adjustSize()

    def print_(self):
        dialog = QPrintDialog(self.printer, self)
        if dialog.exec_():
            painter = QPainter(self.printer)
            rect = painter.viewport()
            size = self.imageLabel.pixmap().size()
            size.scale(rect.size(), Qt.KeepAspectRatio)
            painter.setViewport(rect.x(), rect.y(), size.width(), size.height())
            painter.setWindow(self.imageLabel.pixmap().rect())
            painter.drawPixmap(0, 0, self.imageLabel.pixmap())

    def zoomIn(self):
        self.scaleImage(1.25)

    def zoomOut(self):
        self.scaleImage(0.8)

    def normalSize(self):
        self.imageLabel.adjustSize()
        self.scaleFactor = 1.0

    def fitToWindow(self):
        fitToWindow = self.fitToWindowAct.isChecked()
        self.scrollArea.setWidgetResizable(fitToWindow)
        if not fitToWindow:
            self.normalSize()

        self.updateActions()

    def about(self):
        QMessageBox.about(self, "About Image Viewer",
                "<p>The <b>Image Viewer</b> example shows how to combine "
                "QLabel and QScrollArea to display an image. QLabel is "
                "typically used for displaying text, but it can also display "
                "an image. QScrollArea provides a scrolling view around "
                "another widget. If the child widget exceeds the size of the "
                "frame, QScrollArea automatically provides scroll bars.</p>"
                "<p>The example demonstrates how QLabel's ability to scale "
                "its contents (QLabel.scaledContents), and QScrollArea's "
                "ability to automatically resize its contents "
                "(QScrollArea.widgetResizable), can be used to implement "
                "zooming and scaling features.</p>"
                "<p>In addition the example shows how to use QPainter to "
                "print an image.</p>")

    def createActions(self):
        self.openAct = QAction("&Open...", self, shortcut="Ctrl+O",
                triggered=self.open)

        self.printAct = QAction("&Print...", self, shortcut="Ctrl+P",
                enabled=False, triggered=self.print_)

        self.exitAct = QAction("E&xit", self, shortcut="Ctrl+Q",
                triggered=self.close)

        self.zoomInAct = QAction("Zoom &In (25%)", self, shortcut="Ctrl++",
                enabled=False, triggered=self.zoomIn)

        self.zoomOutAct = QAction("Zoom &Out (25%)", self, shortcut="Ctrl+-",
                enabled=False, triggered=self.zoomOut)

        self.normalSizeAct = QAction("&Normal Size", self, shortcut="Ctrl+S",
                enabled=False, triggered=self.normalSize)

        self.fitToWindowAct = QAction("&Fit to Window", self, enabled=False,
                checkable=True, shortcut="Ctrl+F", triggered=self.fitToWindow)

        self.aboutAct = QAction("&About", self, triggered=self.about)

        self.aboutQtAct = QAction("About &Qt", self,
                triggered=QApplication.instance().aboutQt)

    def createMenus(self):
        self.fileMenu = QMenu("&File", self)
        self.fileMenu.addAction(self.openAct)
        self.fileMenu.addAction(self.printAct)
        self.fileMenu.addSeparator()
        self.fileMenu.addAction(self.exitAct)

        self.viewMenu = QMenu("&View", self)
        self.viewMenu.addAction(self.zoomInAct)
        self.viewMenu.addAction(self.zoomOutAct)
        self.viewMenu.addAction(self.normalSizeAct)
        self.viewMenu.addSeparator()
        self.viewMenu.addAction(self.fitToWindowAct)

        self.helpMenu = QMenu("&Help", self)
        self.helpMenu.addAction(self.aboutAct)
        self.helpMenu.addAction(self.aboutQtAct)

        self.menuBar().addMenu(self.fileMenu)
        self.menuBar().addMenu(self.viewMenu)
        self.menuBar().addMenu(self.helpMenu)

    def updateActions(self):
        self.zoomInAct.setEnabled(not self.fitToWindowAct.isChecked())
        self.zoomOutAct.setEnabled(not self.fitToWindowAct.isChecked())
        self.normalSizeAct.setEnabled(not self.fitToWindowAct.isChecked())

    def scaleImage(self, factor):
        self.scaleFactor *= factor
        self.imageLabel.resize(self.scaleFactor * self.imageLabel.pixmap().size())

        self.adjustScrollBar(self.scrollArea.horizontalScrollBar(), factor)
        self.adjustScrollBar(self.scrollArea.verticalScrollBar(), factor)

        self.zoomInAct.setEnabled(self.scaleFactor < 3.0)
        self.zoomOutAct.setEnabled(self.scaleFactor > 0.333)

    def adjustScrollBar(self, scrollBar, factor):
        scrollBar.setValue(int(factor * scrollBar.value()
                                + ((factor - 1) * scrollBar.pageStep()/2)))


    def mousePressEvent (self, eventQMouseEvent):
        self.originQPoint = self.scrollArea.mapFrom(self, eventQMouseEvent.pos())
        #self.originQPoint = eventQMouseEvent.pos()
        self.currentQRubberBand = QtWidgets.QRubberBand(QtWidgets.QRubberBand.Rectangle, self)
        self.currentQRubberBand.setGeometry(QtCore.QRect(self.originQPoint, QtCore.QSize()))
        self.currentQRubberBand.show()

    def mouseMoveEvent (self, eventQMouseEvent):
        self.x = int(eventQMouseEvent.x())
        self.y = int(eventQMouseEvent.y())
        text1 = str(self.x)
        text2 = str(self.y)
        #print(self.x,self.y)
        QtWidgets.QToolTip.showText(eventQMouseEvent.pos() , "X: "+text1+" "+"Y: "+text2,self)
        if self.currentQRubberBand.isVisible():
            self.currentQRubberBand.setGeometry(QtCore.QRect(self.originQPoint, eventQMouseEvent.pos()).normalized() & self.imageLabel.pixmap().rect())

    def mouseReleaseEvent (self, eventQMouseEvent):
        self.currentQRubberBand.hide()
        currentQRect = self.currentQRubberBand.geometry()
        self.currentQRubberBand.deleteLater()
        cropQPixmap = self.imageLabel.pixmap().copy(currentQRect)
        cropQPixmap.save('output.png')

if __name__ == '__main__':
    import sys
    from PyQt5 import QtGui, QtCore, QtWidgets

    app = QApplication(sys.argv)
    imageViewer = ImageViewer()
    imageViewer.show()
    sys.exit(app.exec_())
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
Far
  • 121
  • 1
  • 11

1 Answers1

1

It is better in these cases that the QRubberBand is the son of the QLabel so there will be no need to make many transformations.

On the other hand, the coordinates of the event are related to the window, so we have to convert it to the coordinates of the QLabel. For this a simple methodology is to convert the local coordinate with respect to the window to global coordinates and then the global coordinates to local coordinates with respect to the QLabel.

And finally when you scale the image you affect the coordinates since the currentQRect is relative to the scaled QLabel but the internal QPixmap is not scaled.

def mousePressEvent (self, event):
    self.originQPoint = self.imageLabel.mapFromGlobal(self.mapToGlobal(event.pos())) 
    self.currentQRubberBand = QtWidgets.QRubberBand(QtWidgets.QRubberBand.Rectangle, self.imageLabel)
    self.currentQRubberBand.setGeometry(QtCore.QRect(self.originQPoint, QtCore.QSize()))
    self.currentQRubberBand.show()

def mouseMoveEvent (self, event):
    p = self.imageLabel.mapFromGlobal(self.mapToGlobal(event.pos()))
    QtWidgets.QToolTip.showText(event.pos() , "X: {} Y: {}".format(p.x(), p.y()), self)
    if self.currentQRubberBand.isVisible() and self.imageLabel.pixmap() is not None:
        self.currentQRubberBand.setGeometry(QtCore.QRect(self.originQPoint, p).normalized() & self.imageLabel.rect())

def mouseReleaseEvent (self, event):
    self.currentQRubberBand.hide()
    currentQRect = self.currentQRubberBand.geometry()
    self.currentQRubberBand.deleteLater()
    if self.imageLabel.pixmap() is not None:
        tr = QtGui.QTransform()
        if self.fitToWindowAct.isChecked():
            tr.scale(self.imageLabel.pixmap().width()/self.scrollArea.width(), 
                self.imageLabel.pixmap().height()/self.scrollArea.height())
        else:
            tr.scale(1/self.scaleFactor, 1/self.scaleFactor)
        r = tr.mapRect(currentQRect)
        cropQPixmap = self.imageLabel.pixmap().copy(r)
        cropQPixmap.save('output.png')
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
  • There is still an issue with capturing ROI around image boundaries when it is zoomed in. Any ideas? – Far Dec 09 '18 at 05:51
  • @Far You could explain what you mean, do you mean output.png? Or what? – eyllanesc Dec 09 '18 at 05:59
  • When I load an image in GUI and zoom in using view menu or Ctrl++, mouse clicks are not registered around the boundaries of the zoomed in image. I cannot drag a rectangle or draw ROI. This is before *.png file creation – Far Dec 09 '18 at 06:32
  • @Far I see that behavior on the farthest side of the topleft, do you mean that region? – eyllanesc Dec 09 '18 at 06:34
  • @Far I just updated the code, try it and tell me if the problem persists – eyllanesc Dec 09 '18 at 06:42
  • Awesome! It works as expected. Would you mind explaining your modified solution. – Far Dec 09 '18 at 06:53
  • @Far In your initial code you place the following condition: `... & self.imageLabel.pixmap().rect()`, with it you are indicating that the maximum area is equal to the size of the QPixmap but when you enlarge the QLabel the size of the QLabel does not match the QPixmap, so instead of taking as a reference the size of the QPixmap you must take into account the size of the QLabel : `... & self.imageLabel.rect()` – eyllanesc Dec 09 '18 at 06:56
  • One more issue that I just noticed is that when Fit to Window option is checked, it captures the wrong region – Far Dec 10 '18 at 18:57