前情提要
- 最近在看强化学习,想着快速做一个 MDP 的可视化,主体是一个画板,类似visio那样的,然后可以实时运行RL算法看价值变化情况
- 但问题是,我可视化工具就会用一个PyQt5,还是半瓶水的水平…所以就想着不要造轮子。一开始感觉这东西和自动机(DFA)或者图灵机差不多,找个可视化的开源库改改就行了,实在不行思维导图的库可能也差不多。没想到啊,真就找不到这种库,又转头去找开源代码什么的,也是寥寥…
- 最后狠下心来自己用
QPainter
实现,到目前为止已经搞了有六七个小时,越搞感觉越麻烦,代码也有混乱化的趋势,麻烦得不想弄了… - 没想到刚刚搜画箭头的方法,发现PyQt5居然有个图形视图框架(Graphics-View),就是专门用来做我这种东西的…裂开,怪不得找不到库…于是,果断放弃自己搞,回头有空学一下那个东西再说。
- 耽误两天时间,教训是磨刀不误砍柴工…开学就要真正读研了,RL/DL/传统ML三个方向基础还没打好,烦
- 现在这个半成品有点弃之可惜,干脆发上来水一篇博客好了
demo演示
-
本来计划绘制的 MDP 示意图,大圈是状态节点,小圈是动作节点,中间用有向边连接
-
目前只做了绘制圆形(状态/动作节点),拖动,缩放,框选等功能,演示如下
我做的这部分在图形视图框架中已经有了完整的轮子…
代码
- 大概的思路是,
- 绘制部分作为一个widget对象嵌入主窗口,称其为 “画布”
- MDP 中的核心元素是节点和连线,于是把它们抽象为 “节点” 和 “连线” 对象,封装相关控制方法,由 “画布” 对象进行实例化、管理和控制
- 在 “画布” widget上重载各种鼠标事件,并利用
QPainter
提供的图形绘制方法绘图 - 主窗口其他部分实现外围功能,比如文件管理、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_())