最开始尝试用QLabel实现,主要原理是重写鼠标点击和松开事件、重写PaintEvent()。效果丝滑,优点是简单好实现,逻辑清晰好管理。缺点是大小没法调整,如果画错了或者画歪了,只能删掉重画:
(标注管理功能不属于本文讨论内容,在此不详述。)
在QLabel上画的东西是在画板上的,也就是基本不能手动改大小。因此我又尝试用QGraphicsView类,在这个类中的图元是可以移动位置的,基本思想是在鼠标点击和松开处画两个可选中、可移动的点/圈,然后以这两个点作为左上/右下角坐标,得到Bbox框。
实现效果贴在下面,代码之前整理在这个博客,就不在这里详述了。
但是上个方法有个缺点:在鼠标点击点移动的时候,边框不会跟着实时移动。然后我又参考了这篇优秀的博客,对标注框功能做了改进:
可以看到在调整BoundingBox的时候,边框的线可以跟着移动。
注:关于那个高亮红框的功能:显然还有一点bug:在调整右下角坐标的时候可以跟着调,但是在调整左上角时会错。这个bug还没de,但是不影响标记框的代码。(这个高亮功能我是用QGraphicsView和QLabel重叠起来做的,Bbox是画在上层的QGraphicsView上的,红色高亮框是画在下层的QLabel上的。这部分代码不是本文讨论的内容。)
继承QLabel(如果不这样做,把图放在QLabel上,标注框会在图片下面,类似下图效果)
PicLabel类,继承QLabel类,__init__()方法中
class PicLabel(QLabel):
def __init__(self, parent=None):
super().__init__()
# 初始化鼠标的位置/bbox位置
self.flag = False
self.x1 = 0 # 左上角坐标
self.y1 = 0
self.x2 = 0 # 右下角坐标
self.y2 = 0
self.x2_realtime = 0 # 鼠标当前位置的坐标
self.y2_realtime = 0
self.bboxPointList = [] # 用来存放bbox左上和右下坐标及label,每个元素以(x1,y1,x2,y2,text)的形式储存
self.labelList = [] # 存放label,会展示在旁边的listview中。
self.defaultLabelId = 0
self.drawLabelFlag = -1 # 是否加了一个框,因为弹出的输入label名字的对话框,可以点取消而不画Bbox
然后重写PicLabel类的鼠标点击方法和释放方法,重点是存下坐标,用来之后画框。
def mousePressEvent(self,event):
self.flag = True
self.x1 = event.x()
self.y1 = event.y()
def mouseReleaseEvent(self, event):
self.flag = False
self.x2 = event.x()
self.y2 = event.y()
self.x2_realtime = self.x1
self.y2_realtime = self.y1 # 这样就不用画出实时框了
text, ok = QInputDialog().getText(QWidget(), '添加Label', '输入label:')
if ok and text:
text = self.getSpecialLabel(text) # 这个函数是为了标签名不重复
self.savebbox(self.x1, self.y1, self.x2, self.y2, text)
self.labelList.append(text)
self.drawLabelFlag *= -1 # 将标记变为正,表示画了
elif ok:
self.defaultLabelId += 1
defaultLabel = 'label' + str(self.defaultLabelId)
self.savebbox(self.x1, self.y1, self.x2, self.y2, defaultLabel) # 这个函数在下面有解释
self.labelList.append(defaultLabel)
self.drawLabelFlag *= -1
# event.ignore() # 将信号同时发给父部件。这句话是方便标签管理用的,和画label无关
在 mouseReleaseEvent(self, event) 方法里面,有几个方法说明一下。
首先, getSpecialLabel 方法,这是为了每个标签不重复。(这个功能也是为了标签管理方便弄的,如果没这个需求,可以不要这个方法)
def getSpecialLabel(self, text):
# 获得不重名的label
index = 0
text_new = text
for label in self.labelList:
if text == label.split(' ')[0]:
index += 1
text_new = text + ' ' + str(index)
return text_new
其次,savebbox(),将框的位置保存在list中:
def savebbox(self, x1, y1, x2, y2, text):
bbox = (x1, y1, x2, y2, text) # 两个点的坐标以一个元组的形式储存,最后一个元素是label
self.bboxPointList.append(bbox)
然后还想画出实时框,所以要获得每刻鼠标的位置,重写mouseMoveEvent():
def mouseMoveEvent(self,event):
if self.flag:
self.x2_realtime = event.x()
self.y2_realtime = event.y()
self.update()
写完了这些方法,鼠标点击这个自定义Label的时候会把鼠标的坐标存起来,但是在软件界面上并不会发生什么变化,因为还没有重写paintEvent():
注:这个方法是在控件更新的时候自动调用的,所以不要在代码里显式调用它,会报错的。
def paintEvent(self, event):
super().paintEvent(event)
painter = QPainter()
painter.begin(self)
for point in self.bboxPointList:
rect = QRect(point[0], point[1], abs(point[0]-point[2]), abs(point[1]-point[3]))
painter.setPen(QPen(Qt.red, 2, Qt.SolidLine))
painter.drawRect(rect)
painter.drawText(point[0], point[1], point[4])
# 实时显示
rect_realtime = QRect(self.x1, self.y1, abs(self.x1-self.x2_realtime), abs(self.y1-self.y2_realtime))
painter.setPen(QPen(Qt.blue, 1, Qt.SolidLine))
painter.drawRect(rect_realtime)
painter.end()
至此,自定义的继承QLabel的、可以画Bbox的 钮祜禄 · QLabel 就写好了。然后随便在哪个程序里把它实例化就可以用了,就像用一般的QLabel那么用,只是它有特殊的Bbox技能。
完整代码如下:(直接运行这段代码是什么都不会得到的啊!要在MainWindow之类里面实例化才行。)
from PyQt5.QtCore import QRect, Qt, QObject, pyqtSignal
from PyQt5.QtGui import QPainter, QPen
from PyQt5.QtWidgets import QLabel, QInputDialog, QWidget, QTreeWidget
# class Comunicate(QObject):
# drawLabelSignal = pyqtSignal(list)
class PicLabel(QLabel):
def __init__(self, parent=None):
super().__init__()
# 初始化鼠标的位置/bbox位置
self.flag = False
self.x1 = 0
self.y1 = 0
self.x2 = 0
self.y2 = 0
self.x2_realtime = 0
self.y2_realtime = 0
self.bboxPointList = [] # 用来存放bbox左上和右下坐标及label,每个元素以(x1,y1,x2,y2,text)的形式储存
self.labelList = [] # 存放label,会展示在旁边的listview中。现在保证了不会重名
self.defaultLabelId = 0
self.drawLabelFlag = -1 # 是否加了一个框,因为可以点取消而不画Bbox
def mousePressEvent(self,event):
self.flag = True
self.x1 = event.x()
self.y1 = event.y()
def mouseReleaseEvent(self, event):
self.flag = False
self.x2 = event.x()
self.y2 = event.y()
self.x2_realtime = self.x1
self.y2_realtime = self.y1 # 这样就不用画出实时框了
text, ok = QInputDialog().getText(QWidget(), '添加Label', '输入label:')
if ok and text:
text = self.getSpecialLabel(text)
self.savebbox(self.x1, self.y1, self.x2, self.y2, text)
self.labelList.append(text)
self.drawLabelFlag *= -1 # 将标记变为正,表示画了
elif ok:
self.defaultLabelId += 1
defaultLabel = 'label' + str(self.defaultLabelId)
self.savebbox(self.x1, self.y1, self.x2, self.y2, defaultLabel)
self.labelList.append(defaultLabel)
self.drawLabelFlag *= -1
event.ignore() # 将信号同时发给父部件
def getSpecialLabel(self, text):
# 获得不重名的label
index = 0
text_new = text
for label in self.labelList:
if text == label.split(' ')[0]:
index += 1
text_new = text + ' ' + str(index)
return text_new
def mouseMoveEvent(self,event):
if self.flag:
self.x2_realtime = event.x()
self.y2_realtime = event.y()
self.update()
def savebbox(self, x1, y1, x2, y2, text):
bbox = (x1, y1, x2, y2, text) # 两个点的坐标以一个元组的形式储存,最后一个元素是label
self.bboxPointList.append(bbox)
def paintEvent(self, event):
super().paintEvent(event)
painter = QPainter()
painter.begin(self)
for point in self.bboxPointList:
rect = QRect(point[0], point[1], abs(point[0]-point[2]), abs(point[1]-point[3]))
painter.setPen(QPen(Qt.red, 2, Qt.SolidLine))
painter.drawRect(rect)
painter.drawText(point[0], point[1], point[4])
# 实时显示
rect_realtime = QRect(self.x1, self.y1, abs(self.x1-self.x2_realtime), abs(self.y1-self.y2_realtime))
painter.setPen(QPen(Qt.blue, 1, Qt.SolidLine))
painter.drawRect(rect_realtime)
painter.end()
这部分精华在这篇优秀的博客里(建议好好读一下,详细学习GraphicsView,读到后面edge的部分,再看下面的内容),我主要对里面的edge做了改写,关键是对path做了改写。
关键的代码贴在这里(如果认真读了上面的,应该能很快看懂下面的。看不懂也没关系,而且如果懒得读那个博客也没关系,后文会对整个代码做解释):
# 关键改动部分
def calc_path(self): # 计算线条的路径
path = QPainterPath(QPointF(self.pos_src[0], self.pos_src[1])) # 起点
path.lineTo(self.pos_dst[0], self.pos_src[1])
path.lineTo(self.pos_dst[0], self.pos_dst[1])
path.moveTo(self.pos_src[0], self.pos_src[1])
path.lineTo(self.pos_src[0], self.pos_dst[1])
path.lineTo(self.pos_dst[0], self.pos_dst[1])
font = QFont("Helvetica [Cronyx]", 12)
path.addText(self.pos_src[0], self.pos_src[1], font, self.edge_wrap.labelText)
self.information['coordinates'] = str([self.pos_src[0], self.pos_src[1], self.pos_dst[0], self.pos_dst[1]])
self.information['class'] = self.edge_wrap.labelText
return path
主要思路还是左上和右下画两个圈,然后用这两个圈的坐标画一个框。
先来处理Scene,也就是所有图元安放的地方。
GraphicScene 继承 QGraphicsScene,重点是把场景中的图元(包括左上右下的圈和框(四条边))都存进list,方便管理。
class GraphicScene(QGraphicsScene):
def __init__(self, parent=None):
super().__init__(parent)
# settings
self.grid_size = 20
self.grid_squares = 5
# self._color_background = QColor('#393939')
self._color_background = Qt.transparent # 因为后面的设计中想要两个部件重叠,所以让这个背景为透明,没有这个需求的,用上面那句设定自己喜欢的背景颜色就好
self._color_light = QColor('#2f2f2f')
self._color_dark = QColor('#292929')
self._pen_light = QPen(self._color_light)
self._pen_light.setWidth(1)
self._pen_dark = QPen(self._color_dark)
self._pen_dark.setWidth(2)
self.setBackgroundBrush(self._color_background)
self.setSceneRect(0, 0, 500, 500)
self.nodes = [] # 储存图元
self.edges = [] # 储存连线
self.real_x = 50
def add_node(self, node): # 这个函数可以改成传两个参数node1node2,弄成一组加进self.nodes里
self.nodes.append(node)
self.addItem(node)
def remove_node(self, node):
self.nodes.remove(node)
for edge in self.edges:
if edge.edge_wrap.start_item is node or edge.edge_wrap.end_item is node:
self.remove_edge(edge)
self.removeItem(node)
def add_edge(self, edge):
self.edges.append(edge)
self.addItem(edge)
def remove_edge(self, edge):
self.edges.remove(edge)
self.removeItem(edge)
我们先来处理圈,圈继承QGraphicsEllipseItem类,重点是要重写mouseMoveEvent方法,让圈被点击的时候线能跟着移动。
class GraphicItem(QGraphicsEllipseItem):
def __init__(self, parent=None):
super().__init__(parent)
pen = QPen()
pen.setColor(Qt.red)
pen.setWidth(2.0)
self.setPen(pen)
self.pix = self.setRect(0, 0, 10, 10)
self.width = 10
self.height = 10
self.setFlag(QGraphicsItem.ItemIsSelectable)
self.setFlag(QGraphicsItem.ItemIsMovable)
def mouseMoveEvent(self, event):
super().mouseMoveEvent(event)
# update selected node and its edge
# 如果图元被选中,就更新连线,这里更新的是所有。可以优化,只更新连接在图元上的。
if self.isSelected():
for gr_edge in self.scene().edges:
gr_edge.edge_wrap.update_positions()
上面这个gr_edge是什么嘞?就是框四周的边,是继承QGraphicsPathItem的类的对象,这个类下面说。
另外self.scene().edges中,scene()可以获得当前图元所在的scene,而我们刚刚已经在自定义的继承QGraphicsScene的GraphicScene类中,把所有的图元(包括圈和框(框由四条线组成))存在list里面了,self.scene().edges就可以获得当前scene中所有的框(边)
然后处理边:
class Edge:
'''
线条的包装类
'''
def __init__(self, scene, start_item, end_item, labelText=''):
super().__init__()
# 参数分别为场景、开始图元、结束图元
self.scene = scene
self.start_item = start_item
self.end_item = end_item
self.labelText = labelText
# 线条图形在此创建
self.gr_edge = GraphicEdge(self)
# add edge on graphic scene 一旦创建就添加进scene
self.scene.add_edge(self.gr_edge)
if self.start_item is not None:
self.update_positions()
def store(self):
self.scene.add_edge(self.gr_edge)
def update_positions(self):
patch = self.start_item.width / 2 # 想让线条从图元的中心位置开始,让他们都加上偏移
src_pos = self.start_item.pos()
self.gr_edge.set_src(src_pos.x()+patch, src_pos.y()+patch)
if self.end_item is not None:
end_pos = self.end_item.pos()
self.gr_edge.set_dst(end_pos.x()+patch, end_pos.y()+patch)
else:
self.gr_edge.set_dst(src_pos.x()+patch, src_pos.y()+patch)
self.gr_edge.update()
def remove_from_current_items(self):
self.end_item = None
self.start_item = None
def remove(self):
self.remove_from_current_items()
self.scene.remove_edge(self.gr_edge)
self.gr_edge = None
class GraphicEdge(QGraphicsPathItem):
def __init__(self, edge_wrap, parent=None):
super().__init__(parent)
self.edge_wrap = edge_wrap
print(self.edge_wrap)
self.width = 2.0
self.pos_src = [0, 0] # 线条起始坐标
self.pos_dst = [0, 0] # 线条结束坐标
self._pen = QPen(QColor("#000")) # 画线条的笔
self._pen.setWidthF(self.width)
self._pen_dragging = QPen(QColor("#000")) # 画拖拽线条的笔
self._pen_dragging.setStyle(Qt.DashDotLine)
self._pen_dragging.setWidthF(self.width)
self._mark_pen = QPen(Qt.green)
self._mark_pen.setWidthF(self.width)
self._mark_brush = QBrush()
self._mark_brush.setColor(Qt.green)
self._mark_brush.setStyle(Qt.SolidPattern)
# self.setFlag(QGraphicsItem.ItemIsSelectable)
self.setZValue(-1) # 让线条出现在所有图元的最下层
# 标注信息
self.information = {'coordinates':'', 'class':'', 'name':'', 'scale':'', 'owner':'', 'saliency':''}
def set_src(self, x, y):
self.pos_src = [x, y]
def set_dst(self, x, y):
self.pos_dst = [x, y]
def calc_path(self): # 计算线条的路径
path = QPainterPath(QPointF(self.pos_src[0], self.pos_src[1])) # 起点
path.lineTo(self.pos_dst[0], self.pos_src[1])
path.lineTo(self.pos_dst[0], self.pos_dst[1])
path.moveTo(self.pos_src[0], self.pos_src[1])
path.lineTo(self.pos_src[0], self.pos_dst[1])
path.lineTo(self.pos_dst[0], self.pos_dst[1])
font = QFont("Helvetica [Cronyx]", 12)
path.addText(self.pos_src[0], self.pos_src[1], font, self.edge_wrap.labelText)
self.information['coordinates'] = str([self.pos_src[0], self.pos_src[1], self.pos_dst[0], self.pos_dst[1]])
self.information['class'] = self.edge_wrap.labelText
return path
def boundingRect(self):
return self.shape().boundingRect()
def shape(self):
return self.calc_path()
def paint(self, painter, graphics_item, widget=None):
self.setPath(self.calc_path())
path = self.path()
if self.edge_wrap.end_item is None:
# 包装类中存储了线条开始和结束位置的图元
# 刚开始拖拽线条时,并没有结束位置的图元,所以是None
# 这个线条画的是拖拽路径,点线
painter.setPen(self._pen_dragging)
painter.drawPath(path)
else:
painter.setPen(self._pen)
painter.drawPath(path)
关键部分就是 calc_path(self) ,在原博客中是一条起始点到结束点的线,在这里把它改成一个框,加上一个label。
最后写一个类继承QGraphicsView,重点是:
class GraphicView(QGraphicsView):
def __init__(self, parent=None):
super().__init__(parent)
self.gr_scene = GraphicScene()
self.parent = parent
self.edge_enable = False
self.drag_edge = None
self.real_x = 0 # 暂时没用上的东西
self.real_y = 0
self.x1 = 0 # 记录左上角点位置
self.y1 = 0
self.x2 = 0 # 记录右下角点位置
self.y2 = 0
self.x1_view = 0 # 记录view坐标系下左上角位置
self.y1_view = 0
self.x2_view = 0
self.y2_view = 0
self.mousePressItem = False # 当前是否点击了某个item, 如果点了,把item本身附上去
self.drawLabelFlag = -1 # 是否加了一个框,因为可以点取消而不画Bbox
self.bboxPointList = [] # 用来存放bbox左上和右下坐标及label,每个元素以[x1,y1,x2,y2,text]的形式储存
self.labelList = [] # 存放label,会展示在旁边的listview中。单独放为了保证不重名
self.defaultLabelId = 0
self.bboxList = [] # 存放图元对象和对应的label,方便删除管理, 每个对象都是[item1, item2, edge_item]
self.init_ui()
def init_ui(self):
self.setScene(self.gr_scene)
self.setRenderHints(QPainter.Antialiasing |
QPainter.HighQualityAntialiasing |
QPainter.TextAntialiasing |
QPainter.SmoothPixmapTransform |
QPainter.LosslessImageRendering)
self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setTransformationAnchor(self.AnchorUnderMouse)
self.setDragMode(self.RubberBandDrag)
def mousePressEvent(self, event):
# 转换坐标系
pt = self.mapToScene(event.pos())
self.x1 = pt.x()
self.y1 = pt.y()
self.x1_view = event.x()
self.y1_view = event.y()
print('上层graphic: view-', event.pos(), ' scene-', pt)
item = self.get_item_at_click(event)
if item:
self.mousePressItem = item
if event.button() == Qt.RightButton:
if isinstance(item, GraphicItem):
self.gr_scene.remove_node(item)
elif self.edge_enable:
if isinstance(item, GraphicItem):
# 确认起点是图元后,开始拖拽
self.edge_drag_start(item)
else:
super().mousePressEvent(event) # 如果写到最开头,则线条拖拽功能会不起作用
print('原来如此')
event.ignore()
def get_item_at_click(self, event):
""" Return the object that clicked on. """
pos = event.pos()
item = self.itemAt(pos)
return item
def get_items_at_rubber(self):
""" Get group select items. """
area = self.rubberBandRect()
return self.items(area)
def mouseMoveEvent(self, event):
# 实时更新线条
pos = event.pos()
# self.real_x = event.x()
# self.real_y = event.y()
# print(self.real_x)
# if self.edge_enable and self.drag_edge is not None:
# sc_pos = self.mapToScene(pos)
# self.drag_edge.gr_edge.set_dst(sc_pos.x(), sc_pos.y())
# self.drag_edge.gr_edge.update()
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
self.mousePressItem = False
pt = self.mapToScene(event.pos())
self.x2 = pt.x()
self.y2 = pt.y()
self.x2_view = event.x()
self.y2_view = event.y()
if self.edge_enable:
# 拖拽结束后,关闭此功能
self.edge_enable = False
item = self.get_item_at_click(event)
# 终点图元不能是起点图元,即无环图
if isinstance(item, GraphicItem) and item is not self.drag_start_item:
self.edge_drag_end(item)
else:
self.drag_edge.remove()
self.drag_edge = None
else:
super().mouseReleaseEvent(event)
item = self.get_item_at_click(event) # 获得当前点击的item对象
if not item: # 如果不是点击item,则生成一个新的Bbox
text, ok = QInputDialog().getText(QWidget(), '添加Label', '输入label:')
if ok and text:
text = self.getSpecialLabel(text)
# 实际上存进去的是view坐标系下的坐标
self.savebbox(self.x1_view, self.y1_view, self.x2_view, self.y2_view, text)
self.labelList.append(text)
self.drawBbox(text)
self.drawLabelFlag *= -1 # 将标记变为正,表示画了
elif ok:
self.defaultLabelId += 1
defaultLabel = 'label' + str(self.defaultLabelId)
self.savebbox(self.x1_view, self.y1_view, self.x2_view, self.y2_view, defaultLabel)
self.labelList.append(defaultLabel)
self.drawBbox(defaultLabel)
self.drawLabelFlag *= -1
else: # 如果点击了item,说明想拖动item
print('点击item拖动,更新BboxPointList')
print('更新前bboxPointList:', self.bboxPointList)
index, position = self.findBboxItemIndexFromItem(item)
label_text = self.bboxList[index][2].gr_edge.information['class']
index_in_bboxPointList = self.findBboxFromLabel(label_text)
if position == 1 :
self.bboxPointList[index_in_bboxPointList][0] = self.x2_view
self.bboxPointList[index_in_bboxPointList][1] = self.y2_view
else:
self.bboxPointList[index_in_bboxPointList][2] = self.x2_view
self.bboxPointList[index_in_bboxPointList][3] = self.y2_view
print('更新后bboxPointList:', self.bboxPointList)
event.ignore() # 将信号同时发给父部件
def drawBbox(self, label_text):
item1 = GraphicItem()
item1.setPos(self.x1, self.y1)
self.gr_scene.add_node(item1)
item2 = GraphicItem()
item2.setPos(self.x2, self.y2)
self.gr_scene.add_node(item2)
edge_item = Edge(self.gr_scene, item1, item2, label_text) # 这里原来是self.drag_edge,我给删了
self.bboxList.append([item1, item2, edge_item])
print(self.bboxPointList)
def savebbox(self, x1, y1, x2, y2, text):
bbox = [x1, y1, x2, y2, text] # 两个点的坐标以一个元组的形式储存,最后一个元素是label
self.bboxPointList.append(bbox)
def getSpecialLabel(self, text):
# 获得不重名的label
index = 0
text_new = text
for label in self.labelList:
if text == label.split(' ')[0]:
index += 1
text_new = text + ' ' + str(index)
return text_new
def edge_drag_start(self, item):
self.drag_start_item = item # 拖拽开始时的图元,此属性可以不在__init__中声明
# 开始拖拽线条,注意到拖拽终点为None
self.drag_edge = Edge(self.gr_scene, self.drag_start_item, None)
def edge_drag_end(self, item):
new_edge = Edge(self.gr_scene, self.drag_start_item, item)
self.drag_edge.remove() # 删除拖拽时画的线
self.drag_edge = None
new_edge.store() # 保存最终产生的连接线
def findBboxFromLabel(self, label):
'''
根据label的内容找到self.bboxPointList的index
'''
for i,b in enumerate(self.bboxPointList):
if b[4] == label:
return i
def findBboxItemIndexFromLabel(self, label_text):
'''
根据label的内容找到self.bboxList的index
'''
for i,b in enumerate(self.bboxList):
edge_item = b[2]
text = edge_item.labelText
if text == label_text:
return i
def findBboxItemIndexFromItem(self, item):
# 根据左上角或右下角的item找到此Bbox在数组中的位置
for i,b in enumerate(self.bboxList):
if b[0] == item:
return i, 1 # 第二个参数1代表点击的是左上点
elif b[1] == item:
return i, 2 # 第二个参数2代表点击的是右下点
else:
return -1, -1 # 表示没找着
def removeBbox(self, index):
item1, item2, edge_item = self.bboxList[index]
self.gr_scene.remove_node(item1)
self.gr_scene.remove_node(item2)
# self.gr_scene.remove_edge(edge_item)
del self.bboxList[index]
(这里面有些没被调用的查找index的方法,是用来管理标签的,可以忽略。)
全部代码为:
(这部分代码也是要在某个主程序里面用,直接运行是没有东西的!)
(主程序用另一个python文件装,附在最后面)
import math
from PyQt5.QtWidgets import QGraphicsView, QGraphicsEllipseItem, QGraphicsItem, QGraphicsPathItem, QGraphicsScene, \
QInputDialog, QWidget, QGraphicsTextItem, QGraphicsRectItem
from PyQt5.QtCore import Qt, QPointF, QLine
from PyQt5.QtGui import QPainter, QPen, QColor, QBrush, QPainterPath, QFont
class GraphicView(QGraphicsView):
def __init__(self, parent=None):
super().__init__(parent)
self.gr_scene = GraphicScene()
self.parent = parent
self.edge_enable = False
self.drag_edge = None
self.real_x = 0 # 暂时没用上的东西
self.real_y = 0
self.x1 = 0 # 记录左上角点位置
self.y1 = 0
self.x2 = 0 # 记录右下角点位置
self.y2 = 0
self.x1_view = 0 # 记录view坐标系下左上角位置
self.y1_view = 0
self.x2_view = 0
self.y2_view = 0
self.mousePressItem = False # 当前是否点击了某个item, 如果点了,把item本身附上去
self.drawLabelFlag = -1 # 是否加了一个框,因为可以点取消而不画Bbox
self.bboxPointList = [] # 用来存放bbox左上和右下坐标及label,每个元素以[x1,y1,x2,y2,text]的形式储存
self.labelList = [] # 存放label,会展示在旁边的listview中。单独放为了保证不重名
self.defaultLabelId = 0
self.bboxList = [] # 存放图元对象和对应的label,方便删除管理, 每个对象都是[item1, item2, edge_item]
self.init_ui()
def init_ui(self):
self.setScene(self.gr_scene)
self.setRenderHints(QPainter.Antialiasing |
QPainter.HighQualityAntialiasing |
QPainter.TextAntialiasing |
QPainter.SmoothPixmapTransform |
QPainter.LosslessImageRendering)
self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setTransformationAnchor(self.AnchorUnderMouse)
self.setDragMode(self.RubberBandDrag)
def mousePressEvent(self, event):
# 转换坐标系
pt = self.mapToScene(event.pos())
self.x1 = pt.x()
self.y1 = pt.y()
self.x1_view = event.x()
self.y1_view = event.y()
print('上层graphic: view-', event.pos(), ' scene-', pt)
item = self.get_item_at_click(event)
if item:
self.mousePressItem = item
if event.button() == Qt.RightButton:
if isinstance(item, GraphicItem):
self.gr_scene.remove_node(item)
elif self.edge_enable:
if isinstance(item, GraphicItem):
# 确认起点是图元后,开始拖拽
self.edge_drag_start(item)
else:
super().mousePressEvent(event) # 如果写到最开头,则线条拖拽功能会不起作用
print('原来如此')
event.ignore()
def get_item_at_click(self, event):
""" Return the object that clicked on. """
pos = event.pos()
item = self.itemAt(pos)
return item
def get_items_at_rubber(self):
""" Get group select items. """
area = self.rubberBandRect()
return self.items(area)
def mouseMoveEvent(self, event):
# 实时更新线条
pos = event.pos()
# self.real_x = event.x()
# self.real_y = event.y()
# print(self.real_x)
# if self.edge_enable and self.drag_edge is not None:
# sc_pos = self.mapToScene(pos)
# self.drag_edge.gr_edge.set_dst(sc_pos.x(), sc_pos.y())
# self.drag_edge.gr_edge.update()
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
self.mousePressItem = False
pt = self.mapToScene(event.pos())
self.x2 = pt.x()
self.y2 = pt.y()
self.x2_view = event.x()
self.y2_view = event.y()
if self.edge_enable:
# 拖拽结束后,关闭此功能
self.edge_enable = False
item = self.get_item_at_click(event)
# 终点图元不能是起点图元,即无环图
if isinstance(item, GraphicItem) and item is not self.drag_start_item:
self.edge_drag_end(item)
else:
self.drag_edge.remove()
self.drag_edge = None
else:
super().mouseReleaseEvent(event)
item = self.get_item_at_click(event) # 获得当前点击的item对象
if not item: # 如果不是点击item,则生成一个新的Bbox
text, ok = QInputDialog().getText(QWidget(), '添加Label', '输入label:')
if ok and text:
text = self.getSpecialLabel(text)
# 实际上存进去的是view坐标系下的坐标
self.savebbox(self.x1_view, self.y1_view, self.x2_view, self.y2_view, text)
self.labelList.append(text)
self.drawBbox(text)
self.drawLabelFlag *= -1 # 将标记变为正,表示画了
elif ok:
self.defaultLabelId += 1
defaultLabel = 'label' + str(self.defaultLabelId)
self.savebbox(self.x1_view, self.y1_view, self.x2_view, self.y2_view, defaultLabel)
self.labelList.append(defaultLabel)
self.drawBbox(defaultLabel)
self.drawLabelFlag *= -1
else: # 如果点击了item,说明想拖动item
print('点击item拖动,更新BboxPointList')
print('更新前bboxPointList:', self.bboxPointList)
index, position = self.findBboxItemIndexFromItem(item)
label_text = self.bboxList[index][2].gr_edge.information['class']
index_in_bboxPointList = self.findBboxFromLabel(label_text)
if position == 1 :
self.bboxPointList[index_in_bboxPointList][0] = self.x2_view
self.bboxPointList[index_in_bboxPointList][1] = self.y2_view
else:
self.bboxPointList[index_in_bboxPointList][2] = self.x2_view
self.bboxPointList[index_in_bboxPointList][3] = self.y2_view
print('更新后bboxPointList:', self.bboxPointList)
event.ignore() # 将信号同时发给父部件
def drawBbox(self, label_text):
item1 = GraphicItem()
item1.setPos(self.x1, self.y1)
self.gr_scene.add_node(item1)
item2 = GraphicItem()
item2.setPos(self.x2, self.y2)
self.gr_scene.add_node(item2)
edge_item = Edge(self.gr_scene, item1, item2, label_text) # 这里原来是self.drag_edge,我给删了
self.bboxList.append([item1, item2, edge_item])
print(self.bboxPointList)
def savebbox(self, x1, y1, x2, y2, text):
bbox = [x1, y1, x2, y2, text] # 两个点的坐标以一个元组的形式储存,最后一个元素是label
self.bboxPointList.append(bbox)
def getSpecialLabel(self, text):
# 获得不重名的label
index = 0
text_new = text
for label in self.labelList:
if text == label.split(' ')[0]:
index += 1
text_new = text + ' ' + str(index)
return text_new
def edge_drag_start(self, item):
self.drag_start_item = item # 拖拽开始时的图元,此属性可以不在__init__中声明
# 开始拖拽线条,注意到拖拽终点为None
self.drag_edge = Edge(self.gr_scene, self.drag_start_item, None)
def edge_drag_end(self, item):
new_edge = Edge(self.gr_scene, self.drag_start_item, item)
self.drag_edge.remove() # 删除拖拽时画的线
self.drag_edge = None
new_edge.store() # 保存最终产生的连接线
def findBboxFromLabel(self, label):
'''
根据label的内容找到self.bboxPointList的index
'''
for i,b in enumerate(self.bboxPointList):
if b[4] == label:
return i
def findBboxItemIndexFromLabel(self, label_text):
'''
根据label的内容找到self.bboxList的index
'''
for i,b in enumerate(self.bboxList):
edge_item = b[2]
text = edge_item.labelText
if text == label_text:
return i
def findBboxItemIndexFromItem(self, item):
# 根据左上角或右下角的item找到此Bbox在数组中的位置
for i,b in enumerate(self.bboxList):
if b[0] == item:
return i, 1 # 第二个参数1代表点击的是左上点
elif b[1] == item:
return i, 2 # 第二个参数2代表点击的是右下点
else:
return -1, -1 # 表示没找着
def removeBbox(self, index):
item1, item2, edge_item = self.bboxList[index]
self.gr_scene.remove_node(item1)
self.gr_scene.remove_node(item2)
# self.gr_scene.remove_edge(edge_item)
del self.bboxList[index]
class GraphicScene(QGraphicsScene):
def __init__(self, parent=None):
super().__init__(parent)
# settings
self.grid_size = 20
self.grid_squares = 5
# self._color_background = QColor('#393939')
self._color_background = Qt.transparent
self._color_light = QColor('#2f2f2f')
self._color_dark = QColor('#292929')
self._pen_light = QPen(self._color_light)
self._pen_light.setWidth(1)
self._pen_dark = QPen(self._color_dark)
self._pen_dark.setWidth(2)
self.setBackgroundBrush(self._color_background)
self.setSceneRect(0, 0, 500, 500)
self.nodes = [] # 储存图元
self.edges = [] # 储存连线
self.real_x = 50
def add_node(self, node): # 这个函数可以改成传两个参数node1node2,弄成一组加进self.nodes里
self.nodes.append(node)
self.addItem(node)
def remove_node(self, node):
self.nodes.remove(node)
for edge in self.edges:
if edge.edge_wrap.start_item is node or edge.edge_wrap.end_item is node:
self.remove_edge(edge)
self.removeItem(node)
def add_edge(self, edge):
self.edges.append(edge)
self.addItem(edge)
def remove_edge(self, edge):
self.edges.remove(edge)
self.removeItem(edge)
class GraphicItem(QGraphicsEllipseItem):
def __init__(self, parent=None):
super().__init__(parent)
pen = QPen()
pen.setColor(Qt.red)
pen.setWidth(2.0)
self.setPen(pen)
self.pix = self.setRect(0, 0, 10, 10)
self.width = 10
self.height = 10
self.setFlag(QGraphicsItem.ItemIsSelectable)
self.setFlag(QGraphicsItem.ItemIsMovable)
def mouseMoveEvent(self, event):
super().mouseMoveEvent(event)
# update selected node and its edge
# 如果图元被选中,就更新连线,这里更新的是所有。可以优化,只更新连接在图元上的。
if self.isSelected():
for gr_edge in self.scene().edges:
gr_edge.edge_wrap.update_positions()
class Edge:
'''
线条的包装类
'''
def __init__(self, scene, start_item, end_item, labelText=''):
super().__init__()
# 参数分别为场景、开始图元、结束图元
self.scene = scene
self.start_item = start_item
self.end_item = end_item
self.labelText = labelText
# 线条图形在此创建
self.gr_edge = GraphicEdge(self)
# add edge on graphic scene 一旦创建就添加进scene
self.scene.add_edge(self.gr_edge)
if self.start_item is not None:
self.update_positions()
def store(self):
self.scene.add_edge(self.gr_edge)
def update_positions(self):
patch = self.start_item.width / 2 # 想让线条从图元的中心位置开始,让他们都加上偏移
src_pos = self.start_item.pos()
self.gr_edge.set_src(src_pos.x()+patch, src_pos.y()+patch)
if self.end_item is not None:
end_pos = self.end_item.pos()
self.gr_edge.set_dst(end_pos.x()+patch, end_pos.y()+patch)
else:
self.gr_edge.set_dst(src_pos.x()+patch, src_pos.y()+patch)
self.gr_edge.update()
def remove_from_current_items(self):
self.end_item = None
self.start_item = None
def remove(self):
self.remove_from_current_items()
self.scene.remove_edge(self.gr_edge)
self.gr_edge = None
class GraphicEdge(QGraphicsPathItem):
def __init__(self, edge_wrap, parent=None):
super().__init__(parent)
self.edge_wrap = edge_wrap
print(self.edge_wrap)
self.width = 2.0
self.pos_src = [0, 0] # 线条起始坐标
self.pos_dst = [0, 0] # 线条结束坐标
self._pen = QPen(QColor("#000")) # 画线条的笔
self._pen.setWidthF(self.width)
self._pen_dragging = QPen(QColor("#000")) # 画拖拽线条的笔
self._pen_dragging.setStyle(Qt.DashDotLine)
self._pen_dragging.setWidthF(self.width)
self._mark_pen = QPen(Qt.green)
self._mark_pen.setWidthF(self.width)
self._mark_brush = QBrush()
self._mark_brush.setColor(Qt.green)
self._mark_brush.setStyle(Qt.SolidPattern)
# self.setFlag(QGraphicsItem.ItemIsSelectable)
self.setZValue(-1) # 让线条出现在所有图元的最下层
# 标注信息
self.information = {'coordinates':'', 'class':'', 'name':'', 'scale':'', 'owner':'', 'saliency':''}
def set_src(self, x, y):
self.pos_src = [x, y]
def set_dst(self, x, y):
self.pos_dst = [x, y]
def calc_path(self): # 计算线条的路径
path = QPainterPath(QPointF(self.pos_src[0], self.pos_src[1])) # 起点
path.lineTo(self.pos_dst[0], self.pos_src[1])
path.lineTo(self.pos_dst[0], self.pos_dst[1])
path.moveTo(self.pos_src[0], self.pos_src[1])
path.lineTo(self.pos_src[0], self.pos_dst[1])
path.lineTo(self.pos_dst[0], self.pos_dst[1])
font = QFont("Helvetica [Cronyx]", 12)
path.addText(self.pos_src[0], self.pos_src[1], font, self.edge_wrap.labelText)
self.information['coordinates'] = str([self.pos_src[0], self.pos_src[1], self.pos_dst[0], self.pos_dst[1]])
self.information['class'] = self.edge_wrap.labelText
return path
def boundingRect(self):
return self.shape().boundingRect()
def shape(self):
return self.calc_path()
def paint(self, painter, graphics_item, widget=None):
self.setPath(self.calc_path())
path = self.path()
if self.edge_wrap.end_item is None:
# 包装类中存储了线条开始和结束位置的图元
# 刚开始拖拽线条时,并没有结束位置的图元,所以是None
# 这个线条画的是拖拽路径,点线
painter.setPen(self._pen_dragging)
painter.drawPath(path)
else:
painter.setPen(self._pen)
painter.drawPath(path)
上面所有的代码都放在一个python文件里,叫view.py。主程序放在另一个python文件里:
import sys
import cgitb
from PyQt5.QtWidgets import QApplication
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QMainWindow
from view import GraphicView, GraphicScene # 上面所有的代码都放在一个python文件里,叫view.py
cgitb.enable(format("text"))
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.scene = GraphicScene(self)
self.view = GraphicView(self.scene, self)
self.setMinimumHeight(500)
self.setMinimumWidth(500)
self.setCentralWidget(self.view)
self.setWindowTitle("Graphics Demo")
def demo_run():
app = QApplication(sys.argv)
demo = MainWindow()
# compatible with Mac Retina screen.
app.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
app.setAttribute(Qt.AA_EnableHighDpiScaling, True)
# show up
demo.show()
sys.exit(app.exec_())
if __name__ == '__main__':
demo_run()
以上。