项目demo —— PyQt5简单画板程序

前情提要

  • 最近在看强化学习,想着快速做一个 MDP 的可视化,主体是一个画板,类似visio那样的,然后可以实时运行RL算法看价值变化情况
  • 但问题是,我可视化工具就会用一个PyQt5,还是半瓶水的水平…所以就想着不要造轮子。一开始感觉这东西和自动机(DFA)或者图灵机差不多,找个可视化的开源库改改就行了,实在不行思维导图的库可能也差不多。没想到啊,真就找不到这种库,又转头去找开源代码什么的,也是寥寥…
  • 最后狠下心来自己用 QPainter 实现,到目前为止已经搞了有六七个小时,越搞感觉越麻烦,代码也有混乱化的趋势,麻烦得不想弄了…
  • 没想到刚刚搜画箭头的方法,发现PyQt5居然有个图形视图框架(Graphics-View),就是专门用来做我这种东西的…裂开,怪不得找不到库…于是,果断放弃自己搞,回头有空学一下那个东西再说。
  • 耽误两天时间,教训是磨刀不误砍柴工…开学就要真正读研了,RL/DL/传统ML三个方向基础还没打好,烦
  • 现在这个半成品有点弃之可惜,干脆发上来水一篇博客好了

demo演示

  • 本来计划绘制的 MDP 示意图,大圈是状态节点,小圈是动作节点,中间用有向边连接
    在这里插入图片描述

  • 目前只做了绘制圆形(状态/动作节点),拖动,缩放,框选等功能,演示如下
    在这里插入图片描述
    我做的这部分在图形视图框架中已经有了完整的轮子…

代码

  • 大概的思路是,
    1. 绘制部分作为一个widget对象嵌入主窗口,称其为 “画布”
    2. MDP 中的核心元素是节点和连线,于是把它们抽象为 “节点” 和 “连线” 对象,封装相关控制方法,由 “画布” 对象进行实例化、管理和控制
    3. 在 “画布” widget上重载各种鼠标事件,并利用 QPainter 提供的图形绘制方法绘图
    4. 主窗口其他部分实现外围功能,比如文件管理、RL算法测试等
  • 目前一共就做了节点、画布、主窗口(编辑器)这三个类,下面三段复制到三个.py文件,放在同一个文件夹下就可以运行了

1. Node 节点类

  • 主体是一个圆形,四周有四个控制点用于缩放大小
    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()   # 中心坐标
            self.lastCenterPonit = None     # 上次中心坐标
            self.ctrlPoints = [QtCore.QPoint(),QtCore.QPoint(),QtCore.QPoint(),QtCore.QPoint()] # 控制点:左上/右上/右下/左下
            self.ctrlPointsRadius = 4       # 控制点半径
    
            self.fixPoint = fixPoint        # init和resize时直径线段固定点 
            self.movePoint = None           # init和resize时直径线段移动点
            self.canvas = canvas           	# 画布对象引用
            self.type = type                # 'action' or 'state'
            self.text = text             	# 提示文字
            self.color = COLORS['select']
            
            self.radius = 0
            self.zoom = 1.0
            self.selected = False   # 处于选中状态
            self.drawn = False      # 绘制结束
            self.resizing = False   # 正在缩放 
    
        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()
    	
    	# 重置控制点
        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)
    
    	# 光标是否指向圆内
        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
    	
    	# 光标指向哪个控制点
        def cursorInCtrlPoint(self,cursorPos):
            for i in range(4):
                point = self.ctrlPoints[i]
                if pointDistance(cursorPos,point) < self.ctrlPointsRadius:
                    return i
            return -1
    
    	# 绘制控制点
        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)
    	
    	# 绘制节点
        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 画布类

  • 继承自 QWidget,核心是要重载鼠标实践,以及 QPainter 绘图
    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()  # 光标拖动起点
            self.endPoint = QtCore.QPoint()    # 光标拖动终点
    
            self.mode = 'select'    # select/state/action/resize/boxing (选择/画状态点/画动作点/节点缩放/框选)
            self.selectedNodes = []	# 目前选中的点
            self.drawingNode = None	# 正在初始化绘制的点
    
            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()  # 鼠标位置
            if ev.buttons() & QtCore.Qt.LeftButton:
                # 创建新节点时调整大小
                if self.drawingNode != None and self.mode in ['state','action']:
                    self.drawingNode.resize(ev.pos())
                # 框选
                elif self.mode == 'boxing':
                    self.endPoint = ev.pos()
                # 移动选中的节点
                elif self.selectedNodes != [] and self.mode == 'select':
                    self.endPoint = ev.pos()
                    for node in self.selectedNodes:
                        node.move(self.startPoint,self.endPoint)
                # 调整节点大小
                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()
    
                
                # 创建新节点
                if self.mode in ['state','action']:
                    self.drawingNode = Node(self,self.mode,ev.pos(),self.mode)
                    self.nodes.append(self.drawingNode)
                # 选中节点调整大小
                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()))
                # 选择节点
                elif self.nodes != [] and self.mode == 'select':
                    clickedSpace = True
                    # 点中节点,单点选择
                    for node in reversed(self.nodes):   # 根据遮挡关系选中节点
                        if node.cursorInside(ev.pos()):
                            if len(self.selectedNodes) <= 1:
                                self.clearSelected()        # 清除上次选中标记
                                node.setSelected(True)
                                self.selectedNodes = [node]
                                self.nodes.remove(node)     # 选中的节点置于顶层
                                self.nodes.append(node)
                            clickedSpace = False
                            break
                    # 点中空白,框选
                    if clickedSpace:                    
                        self.setMode('boxing')          
                # 空白画布,框选模式
                elif self.nodes == []:
                    self.setMode('boxing')          
                else:
                    pass
                self.update()
        
        def mouseReleaseEvent(self, ev):
            if ev.button() == QtCore.Qt.LeftButton:
                # 新节点已创建,转到选中模式
                if self.mode in ['state','action']:
                    self.newNodeSignal.emit()
                    self.selectedNodes = [self.drawingNode]
                    self.drawingNode.setDrawn(True)
                    self.drawingNode.setSelected(True)
                    self.drawingNode = None
                # 节点大小调整完成
                elif self.mode == 'resize':
                    self.setMode('select')
                    self.selectedNodes[0].setResizing(False)
                # 框选完毕
                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     # 返回QPoint对象,为滚轮转过的数值,单位为1/8度
            angleY = angle.y()              # 竖直滚过的距离
            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):
            # 节点
            if self.nodes != []:
                for node in self.nodes:
                    node.print()
                
            # 选择框
            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 编辑器类

  • 这个就是主窗口
    # -*- 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)
    		
    		# 一旦绘制新节点,转入选中状态
            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):
        	# 图标变化以指示当前模式(resize和boxing都属于select模式)
            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
                
            # 设置模式
            self.canves.setMode(mode)
    
    if __name__ == '__main__':
        app = QApplication(sys.argv)
        editor = Editor()
        editor.show()
        sys.exit(app.exec_())
    
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

云端FFF

所有博文免费阅读,求打赏鼓励~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值