Project demo - PyQt5 simple Sketchpad program

Posted by php_joe on Mon, 17 Jan 2022 10:57:49 +0100

previously on

  • Recently, I was watching reinforcement learning and wanted to quickly make a visualization of MDP. The main body is a drawing board, similar to visio, and then I can run RL algorithm in real time to see the change of value
  • But the problem is, my visualization tool will use a PyQt5, or the level of half a bottle of water... So I thought not to make wheels. At first, I felt that this thing was similar to automata (DFA) or Turing machine. Just find a visual Open-Source Library to change it. It really didn't work. The library of mind mapping may also be similar. Unexpectedly, I can't find such a library, and I turn to find open source code or something. It's also very few
  • Finally, I've worked hard to implement it with QPainter. So far, it's been six or seven hours. The more I do, the more trouble I feel. The code also tends to be chaotic. I don't want to do it anymore
  • I didn't expect that just by searching and drawing arrows, I found that PyQt5 actually has a graphics view framework, which is specially used for things like me... Crack, no wonder I can't find the library... So I decided to give up my own work and learn that thing when I have time.
  • It takes two days. The lesson is that sharpening the knife doesn't miss the firewood chopper... It's time to really go to graduate school at the beginning of school. The foundation of RL/DL / traditional ML has not been laid yet. It's annoying
  • Now this semi-finished product is a pity to abandon. Just send a blog

Demo demo

  • In the MDP schematic diagram originally planned to be drawn, the large circle is the state node, the small circle is the action node, and the middle is connected with a directed edge

  • At present, only the functions of drawing circle (status / action node), dragging, scaling, box selection and so on are done. The demonstration is as follows

    This part I have done has a complete wheel in the graphic view frame

code

  • The general idea is,
    1. The drawing part is embedded in the main window as a widget object, which is called "canvas"
    2. The core elements in MDP are nodes and wires, so they are abstracted as "node" and "wire" objects, encapsulated with relevant control methods, and instantiated, managed and controlled by "canvas" objects
    3. Reload various mouse events on the canvas widget and draw using the graphics drawing method provided by QPainter
    4. Other parts of the main window realize peripheral functions, such as file management, RL algorithm test, etc
  • At present, there are three classes: node, canvas and main window (Editor). The following three paragraphs are copied to three py file, put it in the same folder and you can run it

1. Node class

  • The body is a circle with four control points around it for scaling
    from PyQt5 import QtGui, QtCore, QtWidgets
    import math
    
    COLORS = {'state':'#DDDDDD','action':'#888888','select':'#FAC8C8'}
    X = [-0.707,0.707,0.707,-0.707]
    Y = [-0.707,-0.707,0.707,0.707]
    
    
    def pointDistance(point1,point2):
        deltaX = point1.x()-point2.x()
        deltaY = point1.y()-point2.y()
        return math.sqrt(deltaY*deltaY + deltaX*deltaX)
    
    class Node:
        def __init__(self,canvas,type,fixPoint,text='S'):
            self.center = QtCore.QPoint()   # Central coordinates
            self.lastCenterPonit = None     # Last central coordinate
            self.ctrlPoints = [QtCore.QPoint(),QtCore.QPoint(),QtCore.QPoint(),QtCore.QPoint()] # Control points: top left / top right / bottom right / bottom left
            self.ctrlPointsRadius = 4       # Control point radius
    
            self.fixPoint = fixPoint        # Fixed point of diameter segment when init and resize 
            self.movePoint = None           # When init and resize, the diameter segment moves the point
            self.canvas = canvas           	# Canvas object reference
            self.type = type                # 'action' or 'state'
            self.text = text             	# Prompt text
            self.color = COLORS['select']
            
            self.radius = 0
            self.zoom = 1.0
            self.selected = False   # Is selected
            self.drawn = False      # End of drawing
            self.resizing = False   # Zooming 
    
        def setResizing(self,resizing,moveIndex=-1):
            self.resizing = resizing
            if not resizing:
                self.fixPoint = None
            elif moveIndex != -1:
                fixIndex = moveIndex-2 if moveIndex >=2 else moveIndex+2
                self.fixPoint = QtCore.QPoint(self.ctrlPoints[fixIndex])
                print('set fix')
            else:
                assert False
    
        def setSelected(self,selected):
            self.selected = selected
            if selected:
                self.color = COLORS['select']
            else:
                self.color = COLORS[self.type]
            self.lastCenterPonit = QtCore.QPoint(self.center)
    
        def setDrawn(self,drawn):
            self.drawn = drawn
            if drawn:
                self.resetCtrlPoints()
    
        def setRadius(self,radius):
            if radius < 0:
                radius = 0
            self.radius = radius
        
        def getRadius(self):
            return self.radius
    
        def setColor(self,color):
            self.color = color
    
        def getType(self):
            return self.type
    
        def resize(self,endPoint):
            self.center.setX(0.5*(self.fixPoint.x() + endPoint.x()))
            self.center.setY(0.5*(self.fixPoint.y() + endPoint.y()))
            self.radius = 0.5*pointDistance(endPoint,self.fixPoint)*self.zoom
    
        def move(self,startPoint,endPoint):
            self.center.setX(self.lastCenterPonit.x() + endPoint.x() - startPoint.x())
            self.center.setY(self.lastCenterPonit.y() + endPoint.y() - startPoint.y())
            self.resetCtrlPoints()
    	
    	# Reset control point
        def resetCtrlPoints(self):
            for i in range(4):
                point = self.ctrlPoints[i]
                point.setX(self.center.x()+X[i]*self.radius)
                point.setY(self.center.y()+Y[i]*self.radius)
    
    	# Does the cursor point inside the circle
        def cursorInside(self,cursorPos):
            if not self.selected:
                return pointDistance(cursorPos,self.center) < self.radius
            else:
                return pointDistance(cursorPos,self.center) < self.radius or self.cursorInCtrlPoint(cursorPos) != -1
    	
    	# Which control point does the cursor point to
        def cursorInCtrlPoint(self,cursorPos):
            for i in range(4):
                point = self.ctrlPoints[i]
                if pointDistance(cursorPos,point) < self.ctrlPointsRadius:
                    return i
            return -1
    
    	# Draw control points
        def printCtrlPoint(self):
            self.canvas.setPainterColor('#FFFFFF')
            if not self.drawn:
                for i in range(4):
                    point = QtCore.QPoint()
                    point.setX(self.center.x()+X[i]*self.radius)
                    point.setY(self.center.y()+Y[i]*self.radius)
                    self.canvas.painter.drawEllipse(point,self.ctrlPointsRadius,self.ctrlPointsRadius)
            else:
                for point in self.ctrlPoints:                
                    self.canvas.painter.drawEllipse(point,self.ctrlPointsRadius,self.ctrlPointsRadius)
    	
    	# Draw node
        def print(self):
            self.canvas.setPainterColor(self.color)
            self.canvas.painter.drawEllipse(self.center,self.radius,self.radius)
            self.canvas.painter.drawText(QtCore.QRect(self.center.x()-self.radius,self.center.y()-self.radius,2*self.radius,2*self.radius),QtCore.Qt.AlignCenter ,self.text)
            if self.selected:
                self.printCtrlPoint()
    

2. Canvas class

  • Inherited from QWidget, the core is to overload mouse practice and QPainter drawing
    from PyQt5 import QtGui, QtCore, QtWidgets
    from sklearn.metrics import pairwise
    import numpy as np
    import math
    from Node import Node
    
    class Canvas(QtWidgets.QWidget):
        newNodeSignal = QtCore.pyqtSignal() 
    
        def __init__(self):
            super().__init__()
            self.brush = QtGui.QBrush(QtGui.QColor('#222222'),QtCore.Qt.SolidPattern)
            self.painter = QtGui.QPainter(self)
            self.nodes = []         # state node & action node 
            
            self.startPoint = QtCore.QPoint()  # Drag the cursor to the start point
            self.endPoint = QtCore.QPoint()    # Drag the cursor to the end point
    
            self.mode = 'select'    # select/state/action/resize/boxing
            self.selectedNodes = []	# Currently selected point
            self.drawingNode = None	# Initializing drawn points
    
            self.initUI()
    
        def initUI(self):
            pass
    
        def setMode(self,mode):
            if self.mode == 'select' and mode != 'resize':
                self.clearSelected()
            self.mode = mode
    
        def setPainterColor(self,color):
            self.brush.setColor(QtGui.QColor(color))
            self.painter.setBrush(self.brush)
    
        def mouseMoveEvent(self, ev):
            pos = ev.pos()  # Mouse position
            if ev.buttons() & QtCore.Qt.LeftButton:
                # Resize when creating new nodes
                if self.drawingNode != None and self.mode in ['state','action']:
                    self.drawingNode.resize(ev.pos())
                # Frame selection
                elif self.mode == 'boxing':
                    self.endPoint = ev.pos()
                # Move the selected node
                elif self.selectedNodes != [] and self.mode == 'select':
                    self.endPoint = ev.pos()
                    for node in self.selectedNodes:
                        node.move(self.startPoint,self.endPoint)
                # Resize nodes
                elif len(self.selectedNodes) == 1 and self.mode == 'resize':
                    node = self.selectedNodes[0]
                    node.resize(ev.pos())
                    node.resetCtrlPoints()
                else:
                    pass
                self.update()
    
        def mousePressEvent(self, ev):
            if ev.button() == QtCore.Qt.LeftButton:
                self.startPoint = self.endPoint = ev.pos()
    
                
                # Create a new node
                if self.mode in ['state','action']:
                    self.drawingNode = Node(self,self.mode,ev.pos(),self.mode)
                    self.nodes.append(self.drawingNode)
                # Select the node to resize
                elif len(self.selectedNodes) == 1 and self.selectedNodes[0].cursorInCtrlPoint(ev.pos()) != -1:
                    self.setMode('resize')
                    self.selectedNodes[0].setResizing(True,self.selectedNodes[0].cursorInCtrlPoint(ev.pos()))
                # Select node
                elif self.nodes != [] and self.mode == 'select':
                    clickedSpace = True
                    # Node in point, single point selection
                    for node in reversed(self.nodes):   # Select nodes according to occlusion relationship
                        if node.cursorInside(ev.pos()):
                            if len(self.selectedNodes) <= 1:
                                self.clearSelected()        # Clear last check mark
                                node.setSelected(True)
                                self.selectedNodes = [node]
                                self.nodes.remove(node)     # The selected node is placed at the top level
                                self.nodes.append(node)
                            clickedSpace = False
                            break
                    # Click blank in the box to select
                    if clickedSpace:                    
                        self.setMode('boxing')          
                # Blank canvas, box selection mode
                elif self.nodes == []:
                    self.setMode('boxing')          
                else:
                    pass
                self.update()
        
        def mouseReleaseEvent(self, ev):
            if ev.button() == QtCore.Qt.LeftButton:
                # The new node has been created, go to the selected mode
                if self.mode in ['state','action']:
                    self.newNodeSignal.emit()
                    self.selectedNodes = [self.drawingNode]
                    self.drawingNode.setDrawn(True)
                    self.drawingNode.setSelected(True)
                    self.drawingNode = None
                # Node sizing complete
                elif self.mode == 'resize':
                    self.setMode('select')
                    self.selectedNodes[0].setResizing(False)
                # Box selection completed
                elif self.mode == 'boxing':
                    self.boxNodes()
                    self.startPoint.setX(0),self.startPoint.setY(0)
                    self.endPoint.setX(0),self.endPoint.setY(0)
                    self.setMode('select')
                else:
                    pass
                self.update()
        
        def wheelEvent(self, ev):
            angle = ev.angleDelta() / 8     # Returns the QPoint object, which is the value of the roller rotation, and the unit is 1 / 8 degree
            angleY = angle.y()              # Vertical rolling distance
            print(angleY)
    
            #self.update()
        
        def paintEvent(self,event):
            self.painter.begin(self)
            self.paintEvent = event
            self.updateCanvas(self.painter)
            self.painter.end()
    
        def updateCanvas(self,painter):
            # node
            if self.nodes != []:
                for node in self.nodes:
                    node.print()
                
            # Selection box
            self.painter.setBrush(QtCore.Qt.NoBrush)
            self.painter.setPen(QtCore.Qt.darkGreen)
            if self.mode == 'boxing':
                self.painter.drawRect(self.startPoint.x(), self.startPoint.y(), self.endPoint.x() - self.startPoint.x(), self.endPoint.y() - self.startPoint.y())
    
        def clearSelected(self):
            if self.selectedNodes != []:
                for node in self.selectedNodes:
                    node.setSelected(False)
                self.selectedNodes = []
                self.update()
    
        def deleteNode(self):
            if self.selectedNodes != []:
                for node in self.selectedNodes:
                    self.nodes.remove(node)
                self.selectedNodes = []
                self.setMode('select')
                self.update()
        
        def boxNodes(self):
            self.clearSelected()
            for node in self.nodes:
                if min(self.startPoint.x(),self.endPoint.x()) <= node.center.x() <= max(self.startPoint.x(),self.endPoint.x()) and \
                    min(self.startPoint.y(),self.endPoint.y()) <= node.center.y() <= max(self.startPoint.y(),self.endPoint.y()):
                    node.setSelected(True)
                    self.selectedNodes.append(node)
            self.update()
    

3. Editor class

  • This is the main window
    # -*- coding: utf-8 -*-
    from PyQt5 import QtCore, QtWidgets, QtGui
    from PyQt5.QtWidgets import QMainWindow, QApplication, QAction, QGraphicsView ,QScrollArea
    from PyQt5.QtGui import QPainter, QPainterPath, QIcon
    from PyQt5.QtCore import Qt
    import sys
    from Canvas import Canvas
     
    class Editor(QMainWindow):
        
        def __init__(self):
            super().__init__()
            
            self.canves = Canvas()
            self.setupUi()
            self.setupToolBar()
            
        def setupUi(self):
            self.setObjectName("EditorWindow")
            self.resize(760, 544)    
    
            self.centralwidget = QtWidgets.QWidget(self)
            self.gridLayout = QtWidgets.QGridLayout(self.centralwidget)
            self.gridLayout.setObjectName("gridLayout")
            
            self.scroller = QScrollArea(self.centralwidget)
            self.scrollerGridLayout = QtWidgets.QGridLayout(self.scroller)
            self.scrollerGridLayout.setObjectName("scrollerGridLayout")
            self.scrollerGridLayout.addWidget(self.canves,1,1,1,1)
            self.scroller.setLayout(self.scrollerGridLayout)
            self.gridLayout.addWidget(self.scroller,1,1,1,1)
            self.setCentralWidget(self.centralwidget)
    		
    		# Once a new node is drawn, it turns to the selected state
            self.canves.newNodeSignal.connect(lambda:self.setMode('select'))
    
        def setupToolBar(self):
            self.toolbar = self.addToolBar('toolbar')
            self.toolbar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
    
            action_new_file = QAction(QIcon('./images/filenew.png'), 'New File', self)
            #action_new_file.triggered.connect(self.file_new)
            self.toolbar.addAction(action_new_file)
    
            action_save_file = QAction(QIcon('./images/filesave.png'), 'Save File', self)
            #action_save_file.triggered.connect(self.file_save)
            self.toolbar.addAction(action_save_file)
    
            self.action_state_ponit = QAction(QIcon('./images/state.png'), 'State node', self)
            self.action_state_ponit.triggered.connect(lambda:self.setMode('state'))
            self.toolbar.addAction(self.action_state_ponit)
    
            self.action_act_ponit = QAction(QIcon('./images/action.png'), 'Action node', self)
            self.action_act_ponit.triggered.connect(lambda:self.setMode('action'))
            self.toolbar.addAction(self.action_act_ponit)
    
            self.action_selcet_ponit = QAction(QIcon('./images/select2.png'), 'select', self)
            self.action_selcet_ponit.triggered.connect(lambda:self.setMode('select'))
            self.toolbar.addAction(self.action_selcet_ponit)
    
            action_delete_point = QAction(QIcon('./images/delete.png'), 'Delete node', self)
            action_delete_point.triggered.connect(self.canves.deleteNode)
            self.toolbar.addAction(action_delete_point)
    
        def setMode(self,mode):
        	# The icon changes to indicate the current mode (both resize and boxing belong to select mode)
            self.action_state_ponit.setIcon(QIcon('./images/state.png'))
            self.action_act_ponit.setIcon(QIcon('./images/action.png'))
            self.action_selcet_ponit.setIcon(QIcon('./images/select.png'))
            
            if self.canves.mode == mode or mode == 'select':
                self.action_selcet_ponit.setIcon(QIcon('./images/select2.png'))
                mode == 'select'
            elif mode == 'state':
                self.action_state_ponit.setIcon(QIcon('./images/state2.png'))
            elif mode == 'action':
                self.action_act_ponit.setIcon(QIcon('./images/action2.png'))
            else:
                pass
                
            # Setting mode
            self.canves.setMode(mode)
    
    if __name__ == '__main__':
        app = QApplication(sys.argv)
        editor = Editor()
        editor.show()
        sys.exit(app.exec_())
    

Topics: PyQt5