Search code examples
pythonpython-3.xpyqtpyqt4qgraphicsscene

Padding issue while drawing points over QGraphicsScene


I have a PyQt application where I have drawn points using QPainter over a QGraphicsScene and made a drag n drop sort of a thing.

Now, there is one issue which I'm facing and that is I'm unable to drag those points at the extreme corner and edges of QGraphicsScene. It always seems as if some amount of padding or space is left.

How do I get round this problem?

Code:

from collections import deque
from datetime import datetime
import sys
from threading import Thread
import time
import numpy as np

import cv2

from PyQt4 import QtCore, QtGui


class CameraWidget(QtGui.QGraphicsView):
    """Independent camera feed
    Uses threading to grab IP camera frames in the background

    @param width - Width of the video frame
    @param height - Height of the video frame
    @param stream_link - IP/RTSP/Webcam link
    @param aspect_ratio - Whether to maintain frame aspect ratio or force into fraame
    """

    def __init__(self, width, height, stream_link=0, aspect_ratio=False, parent=None, deque_size=1):
        super(CameraWidget, self).__init__(parent)

        # Initialize deque used to store frames read from the stream
        self.deque = deque(maxlen=deque_size)

        self.screen_width = width
        self.screen_height = height
        self.maintain_aspect_ratio = aspect_ratio

        self.camera_stream_link = stream_link

        # Flag to check if camera is valid/working
        self.online = False
        self.capture = None

        self.setScene(QtGui.QGraphicsScene(self))

        self._pixmap_item = self.scene().addPixmap(QtGui.QPixmap())
        
        canvas = Canvas()

        lay = QtGui.QVBoxLayout()
        lay.addWidget(canvas)
        self.setLayout(lay)

        self.load_network_stream()

        # Start background frame grabbing
        self.get_frame_thread = Thread(target=self.get_frame, args=())
        self.get_frame_thread.daemon = True
        self.get_frame_thread.start()

        # Periodically set video frame to display
        self.timer = QtCore.QTimer()
        self.timer.timeout.connect(self.set_frame)
        self.timer.start(0.5)

        print("Started camera: {}".format(self.camera_stream_link))

    def load_network_stream(self):
        """Verifies stream link and open new stream if valid"""

        def load_network_stream_thread():
            if self.verify_network_stream(self.camera_stream_link):
                self.capture = cv2.VideoCapture(self.camera_stream_link)
                self.online = True

        self.load_stream_thread = Thread(target=load_network_stream_thread, args=())
        self.load_stream_thread.daemon = True
        self.load_stream_thread.start()

    def verify_network_stream(self, link):
        """Attempts to receive a frame from given link"""

        cap = cv2.VideoCapture(link)
        if not cap.isOpened():
            return False
        cap.release()
        return True

    def get_frame(self):
        """Reads frame, resizes, and converts image to pixmap"""

        while True:
            try:
                if self.capture.isOpened() and self.online:
                    # Read next frame from stream and insert into deque
                    status, frame = self.capture.read()
                    if status:
                        self.deque.append(frame)
                    else:
                        self.capture.release()
                        self.online = False
                else:
                    # Attempt to reconnect
                    print("attempting to reconnect", self.camera_stream_link)
                    self.load_network_stream()
                    self.spin(2)
                self.spin(0.001)
            except AttributeError:
                pass

    def spin(self, seconds):
        """Pause for set amount of seconds, replaces time.sleep so program doesnt stall"""

        time_end = time.time() + seconds
        while time.time() < time_end:
            QtGui.QApplication.processEvents()

    def set_frame(self):
        """Sets pixmap image to video frame"""

        if not self.online:
            self.spin(1)
            return

        if self.deque and self.online:
            # Grab latest frame
            frame = self.deque[-1]

            frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            h, w, ch = frame.shape
            bytesPerLine = ch * w

            # Convert to pixmap and set to video frame
            image = QtGui.QImage(frame, w, h, bytesPerLine, QtGui.QImage.Format_RGB888)
            pixmap = QtGui.QPixmap.fromImage(image.copy())
            self._pixmap_item.setPixmap(pixmap)
        self.fix_size()

    def resizeEvent(self, event):
        self.fix_size()
        super().resizeEvent(event)

    def fix_size(self):
        self.fitInView(
            self._pixmap_item,
            QtCore.Qt.KeepAspectRatio
            if self.maintain_aspect_ratio
            else QtCore.Qt.IgnoreAspectRatio,
        )


class Window(QtGui.QWidget):
    def __init__(self, cam=None, parent=None):
        super(Window, self).__init__(parent)

        self.showMaximized()

        self.screen_width = self.width()
        self.screen_height = self.height()

        # Create camera widget
        print("Creating Camera Widget...")
        self.camera = CameraWidget(self.screen_width, self.screen_height, cam)

        lay = QtGui.QVBoxLayout(self)
        lay.setContentsMargins(0, 0, 0, 0)
        lay.setSpacing(0)
        lay.addWidget(self.camera)


class Canvas(QtGui.QWidget):

    DELTA = 200 #for the minimum distance        

    def __init__(self, parent=None):
        super(Canvas, self).__init__(parent)
        self.draggin_idx = -1        
        self.points = np.array([[x[0],x[1]] for x in [[100,200], [200,200], [100,400], [200,400]]], dtype=np.float)  
        self.id = None 

        self.points_dict = {}

        for i, x in enumerate(self.points):
            point=(int(x[0]),int(x[1]))
            self.points_dict[i] = point 

    def paintEvent(self, e):
        qp = QtGui.QPainter()
        qp.begin(self)
        self.drawPoints(qp)
        self.drawLines(qp)
        qp.end()

    def drawPoints(self, qp):
        pen = QtGui.QPen()
        pen.setWidth(10)
        pen.setColor(QtGui.QColor('red'))
        qp.setPen(pen)
        for x,y in self.points:
            qp.drawPoint(x,y)        

    def drawLines(self, qp):
        qp.setPen(QtCore.Qt.red)
        qp.drawLine(self.points_dict[0][0], self.points_dict[0][1], self.points_dict[1][0], self.points_dict[1][1])
        qp.drawLine(self.points_dict[1][0], self.points_dict[1][1], self.points_dict[3][0], self.points_dict[3][1])
        qp.drawLine(self.points_dict[3][0], self.points_dict[3][1], self.points_dict[2][0], self.points_dict[2][1])
        qp.drawLine(self.points_dict[2][0], self.points_dict[2][1], self.points_dict[0][0], self.points_dict[0][1])

    def _get_point(self, evt):
        pos = evt.pos()
        if pos.x() < 0:
            pos.setX(0)
        elif pos.x() > self.width():
            pos.setX(self.width())
        if pos.y() < 0:
            pos.setY(0)
        elif pos.y() > self.height():
            pos.setY(self.height())
        return np.array([pos.x(), pos.y()])

    #get the click coordinates
    def mousePressEvent(self, evt):
        if evt.button() == QtCore.Qt.LeftButton and self.draggin_idx == -1:
            point = self._get_point(evt)
            int_point = (int(point[0]), int(point[1]))
            min_dist = ((int_point[0]-self.points_dict[0][0])**2 + (int_point[1]-self.points_dict[0][1])**2)**0.5
            
            for i, x in enumerate(list(self.points_dict.values())):
                distance = ((int_point[0]-x[0])**2 + (int_point[1]-x[1])**2)**0.5
                if min_dist >= distance:
                    min_dist = distance
                    self.id = i
                    
            #dist will hold the square distance from the click to the points
            dist = self.points - point
            dist = dist[:,0]**2 + dist[:,1]**2
            dist[dist>self.DELTA] = np.inf #obviate the distances above DELTA
            if dist.min() < np.inf:
                self.draggin_idx = dist.argmin()        

    def mouseMoveEvent(self, evt):
        if self.draggin_idx != -1:
            point = self._get_point(evt)
            self.points[self.draggin_idx] = point
            self.update()

    def mouseReleaseEvent(self, evt):
        if evt.button() == QtCore.Qt.LeftButton and self.draggin_idx != -1:
            point = self._get_point(evt)
            int_point = (int(point[0]), int(point[1]))
            self.points_dict[self.id] = int_point
            self.points[self.draggin_idx] = point
            self.draggin_idx = -1
            self.update()


camera = 0

if __name__ == "__main__":
    app = QtGui.QApplication([])
    win = Window(camera)
    sys.exit(app.exec_())

Edit:

I've one more requirement.

The mousePressEvent and mouseReleaseEvent in my Canvas class gives me coordinates w.r.t. my monitor resolution, instead I want it w.r.t. QGraphicsView. Say e.g. my screen_resolution is 1920x1080 and the size of my QGraphicsView is 640x480 then I should get points in accordance with 640x480.


Solution

  • The simplest solution would be to add lay.setContentsMargins(0, 0, 0, 0) for the layout of the graphics view:

    class CameraWidget(QtGui.QGraphicsView):
        def __init__(self, width, height, stream_link=0, aspect_ratio=False, parent=None, deque_size=1):
            # ...
            canvas = Canvas()
    
            lay = QtGui.QVBoxLayout()
            lay.addWidget(canvas)
            self.setLayout(lay)
            lay.setContentsMargins(0, 0, 0, 0)
            # ...
    

    But consider that doing all this is not suggested.

    First of all, you don't need a layout for a single widget, as you could just create the widget with the view as a parent and then resize it in the resizeEvent:

            # ...
            self.canvas = Canvas(self)
    
        def resizeEvent(self, event):
            self.fix_size()
            super().resizeEvent(event)
            self.canvas.resize(self.size())
    

    Widgets like QGraphicsView should not have a layout set, it's unsupported and may lead to unwanted behavior or even bugs under certain conditions.

    In any case, it doesn't make a lot of sense to add a widget on top of a QGraphicsView if that widget is used for painting and mouse interaction: QGraphicsView already provides better implementation for that by using QGraphicsRectItem or QGraphicsLineItem.

    And, even if it weren't the case, custom drawing over a graphics view should be done in its drawForeground() implementation.