def loadLabels(self, shapes): s = [] for label, points, line_color, fill_color in shapes: shape = Shape(label=label) for x, y in points: shape.addPoint(QPointF(x, y)) shape.close() s.append(shape) self.addLabel(shape) if line_color: shape.line_color = QColor(*line_color) if fill_color: shape.fill_color = QColor(*fill_color) self.canvas.loadShapes(s)
def addSquare(self, size): #square shape = Shape() if size == SMALL: shape.addPoint(QPointF(50, 0)) shape.addPoint(QPointF(100, 50)) shape.addPoint(QPointF(50, 100)) shape.addPoint(QPointF(0, 50)) elif size == BIG: shape.addPoint(QPointF(0, 0)) shape.addPoint(QPointF(0, 100)) shape.addPoint(QPointF(100, 100)) shape.addPoint(QPointF(100, 0)) shape.close() self.shapes.append(shape) self.repaint()
def addTri(self, direction): if direction == UP: shape = Shape() shape.addPoint(QPointF(50, 0)) shape.addPoint(QPointF(0, 50)) shape.addPoint(QPointF(100, 50)) shape.moveBy(QPointF(0, 0)) shape.close() self.shapes.append(shape) elif direction == DOWN: shape = Shape() shape.addPoint(QPointF(0, 0)) shape.addPoint(QPointF(50, 50)) shape.addPoint(QPointF(100, 0)) shape.moveBy(QPointF(0, 0)) shape.close() self.shapes.append(shape) elif direction == LEFT: shape = Shape() shape.addPoint(QPointF(50, 0)) shape.addPoint(QPointF(0, 50)) shape.addPoint(QPointF(50, 100)) shape.moveBy(QPointF(0, 0)) shape.close() self.shapes.append(shape) elif direction == RIGHT: shape = Shape() shape.addPoint(QPointF(0, 0)) shape.addPoint(QPointF(50, 50)) shape.addPoint(QPointF(0, 100)) shape.moveBy(QPointF(0, 0)) shape.close() self.shapes.append(shape) self.repaint()
class Canvas(QWidget): zoomRequest = pyqtSignal(int) scrollRequest = pyqtSignal(int, int) newShape = pyqtSignal() selectionChanged = pyqtSignal(bool) shapeMoved = pyqtSignal() drawingPolygon = pyqtSignal(bool) CREATE, EDIT = list(range(2)) epsilon = 11.0 def __init__(self, *args, **kwargs): super(Canvas, self).__init__(*args, **kwargs) # Initialise local state. self.mode = self.EDIT self.shapes = [] self.current = None self.selectedShape = None # save the selected shape here self.selectedShapeCopy = None self.lineColor = QColor(0, 0, 255) self.line = Shape(line_color=self.lineColor) self.prevPoint = QPointF() self.offsets = QPointF(), QPointF() self.scale = 1.0 self.pixmap = QPixmap() self.visible = {} self._hideBackround = False self.hideBackround = False self.hShape = None self.hVertex = None self._painter = QPainter() self._cursor = CURSOR_DEFAULT # Menus: self.menus = (QMenu(), QMenu()) # Set widget options. self.setMouseTracking(True) self.setFocusPolicy(Qt.WheelFocus) def enterEvent(self, ev): self.overrideCursor(self._cursor) def leaveEvent(self, ev): self.restoreCursor() def focusOutEvent(self, ev): self.restoreCursor() def isVisible(self, shape): return self.visible.get(shape, True) def drawing(self): return self.mode == self.CREATE def editing(self): return self.mode == self.EDIT def setEditing(self, value=True): self.mode = self.EDIT if value else self.CREATE if not value: # Create self.unHighlight() self.deSelectShape() def unHighlight(self): if self.hShape: self.hShape.highlightClear() self.hVertex = self.hShape = None def selectedVertex(self): return self.hVertex is not None def mouseMoveEvent(self, ev): """Update line with last point and current coordinates.""" pos = self.transformPos(ev.pos()) self.restoreCursor() # Polygon drawing. if self.drawing(): self.overrideCursor(CURSOR_DRAW) if self.current: color = self.lineColor if self.outOfPixmap(pos): # Don't allow the user to draw outside the pixmap. # Project the point to the pixmap's edges. pos = self.intersectionPoint(self.current[-1], pos) elif len(self.current) > 1 and self.closeEnough( pos, self.current[0]): # Attract line to starting point and colorise to alert the user: pos = self.current[0] color = self.current.line_color self.overrideCursor(CURSOR_POINT) self.current.highlightVertex(0, Shape.NEAR_VERTEX) self.line[1] = pos self.line.line_color = color self.repaint() self.current.highlightClear() return # Polygon copy moving. if Qt.RightButton & ev.buttons(): if self.selectedShapeCopy and self.prevPoint: self.overrideCursor(CURSOR_MOVE) self.boundedMoveShape(self.selectedShapeCopy, pos) self.repaint() elif self.selectedShape: self.selectedShapeCopy = self.selectedShape.copy() self.repaint() return # Polygon/Vertex moving. if Qt.LeftButton & ev.buttons(): if self.selectedVertex(): self.boundedMoveVertex(pos) self.shapeMoved.emit() self.repaint() elif self.selectedShape and self.prevPoint: self.overrideCursor(CURSOR_MOVE) self.boundedMoveShape(self.selectedShape, pos) self.shapeMoved.emit() self.repaint() return # Just hovering over the canvas, 2 posibilities: # - Highlight shapes # - Highlight vertex # Update shape/vertex fill and tooltip value accordingly. self.setToolTip("Image") for shape in reversed([s for s in self.shapes if self.isVisible(s)]): # Look for a nearby vertex to highlight. If that fails, # check if we happen to be inside a shape. index = shape.nearestVertex(pos, self.epsilon) if index is not None: if self.selectedVertex(): self.hShape.highlightClear() self.hVertex, self.hShape = index, shape shape.highlightVertex(index, shape.MOVE_VERTEX) self.overrideCursor(CURSOR_POINT) self.setToolTip("Click & drag to move point") self.setStatusTip(self.toolTip()) self.update() break elif shape.containsPoint(pos): if self.selectedVertex(): self.hShape.highlightClear() self.hVertex, self.hShape = None, shape self.setToolTip("Click & drag to move shape '%s'" % shape.label) self.setStatusTip(self.toolTip()) self.overrideCursor(CURSOR_GRAB) self.update() break else: # Nothing found, clear highlights, reset state. if self.hShape: self.hShape.highlightClear() self.update() self.hVertex, self.hShape = None, None def mousePressEvent(self, ev): pos = self.transformPos(ev.pos()) if ev.button() == Qt.LeftButton: if self.drawing(): if self.current and self.current.reachMaxPoints() is False: initPos = self.current[0] minX = initPos.x() minY = initPos.y() targetPos = self.line[1] maxX = targetPos.x() maxY = targetPos.y() self.current.addPoint(QPointF(maxX, minY)) self.current.addPoint(targetPos) self.current.addPoint(QPointF(minX, maxY)) self.current.addPoint(initPos) self.line[0] = self.current[-1] if self.current.isClosed(): self.finalise() elif not self.outOfPixmap(pos): self.current = Shape() self.current.addPoint(pos) self.line.points = [pos, pos] self.setHiding() self.drawingPolygon.emit(True) self.update() else: self.selectShapePoint(pos) self.prevPoint = pos self.repaint() elif ev.button() == Qt.RightButton and self.editing(): self.selectShapePoint(pos) self.prevPoint = pos self.repaint() def mouseReleaseEvent(self, ev): if ev.button() == Qt.RightButton: menu = self.menus[bool(self.selectedShapeCopy)] self.restoreCursor() if not menu.exec_(self.mapToGlobal(ev.pos()))\ and self.selectedShapeCopy: # Cancel the move by deleting the shadow copy. self.selectedShapeCopy = None self.repaint() elif ev.button() == Qt.LeftButton and self.selectedShape: self.overrideCursor(CURSOR_GRAB) def endMove(self, copy=False): assert self.selectedShape and self.selectedShapeCopy shape = self.selectedShapeCopy #del shape.fill_color #del shape.line_color if copy: self.shapes.append(shape) self.selectedShape.selected = False self.selectedShape = shape self.repaint() else: shape.label = self.selectedShape.label self.deleteSelected() self.shapes.append(shape) self.selectedShapeCopy = None def hideBackroundShapes(self, value): self.hideBackround = value if self.selectedShape: # Only hide other shapes if there is a current selection. # Otherwise the user will not be able to select a shape. self.setHiding(True) self.repaint() def setHiding(self, enable=True): self._hideBackround = self.hideBackround if enable else False def canCloseShape(self): return self.drawing() and self.current and len(self.current) > 2 def mouseDoubleClickEvent(self, ev): # We need at least 4 points here, since the mousePress handler # adds an extra one before this handler is called. if self.canCloseShape() and len(self.current) > 3: self.current.popPoint() self.finalise() def selectShape(self, shape): self.deSelectShape() shape.selected = True self.selectedShape = shape self.setHiding() self.selectionChanged.emit(True) self.update() def selectShapePoint(self, point): """Select the first shape created which contains this point.""" self.deSelectShape() if self.selectedVertex(): # A vertex is marked for selection. index, shape = self.hVertex, self.hShape shape.highlightVertex(index, shape.MOVE_VERTEX) return for shape in reversed(self.shapes): if self.isVisible(shape) and shape.containsPoint(point): shape.selected = True self.selectedShape = shape self.calculateOffsets(shape, point) self.setHiding() self.selectionChanged.emit(True) return def calculateOffsets(self, shape, point): rect = shape.boundingRect() x1 = rect.x() - point.x() y1 = rect.y() - point.y() x2 = (rect.x() + rect.width()) - point.x() y2 = (rect.y() + rect.height()) - point.y() self.offsets = QPointF(x1, y1), QPointF(x2, y2) def boundedMoveVertex(self, pos): index, shape = self.hVertex, self.hShape point = shape[index] if self.outOfPixmap(pos): pos = self.intersectionPoint(point, pos) shiftPos = pos - point shape.moveVertexBy(index, shiftPos) lindex = (index + 1) % 4 rindex = (index + 3) % 4 lshift = None rshift = None if index % 2 == 0: rshift = QPointF(shiftPos.x(), 0) lshift = QPointF(0, shiftPos.y()) else: lshift = QPointF(shiftPos.x(), 0) rshift = QPointF(0, shiftPos.y()) shape.moveVertexBy(rindex, rshift) shape.moveVertexBy(lindex, lshift) def boundedMoveShape(self, shape, pos): if self.outOfPixmap(pos): return False # No need to move o1 = pos + self.offsets[0] if self.outOfPixmap(o1): pos -= QPointF(min(0, o1.x()), min(0, o1.y())) o2 = pos + self.offsets[1] if self.outOfPixmap(o2): pos += QPointF(min(0, self.pixmap.width() - o2.x()), min(0, self.pixmap.height() - o2.y())) # The next line tracks the new position of the cursor # relative to the shape, but also results in making it # a bit "shaky" when nearing the border and allows it to # go outside of the shape's area for some reason. XXX #self.calculateOffsets(self.selectedShape, pos) dp = pos - self.prevPoint if dp: shape.moveBy(dp) self.prevPoint = pos return True return False def deSelectShape(self): if self.selectedShape: self.selectedShape.selected = False self.selectedShape = None self.setHiding(False) self.selectionChanged.emit(False) self.update() def deleteSelected(self): if self.selectedShape: shape = self.selectedShape self.shapes.remove(self.selectedShape) self.selectedShape = None self.update() return shape def copySelectedShape(self): if self.selectedShape: shape = self.selectedShape.copy() self.deSelectShape() self.shapes.append(shape) shape.selected = True self.selectedShape = shape self.boundedShiftShape(shape) return shape def boundedShiftShape(self, shape): # Try to move in one direction, and if it fails in another. # Give up if both fail. point = shape[0] offset = QPointF(2.0, 2.0) self.calculateOffsets(shape, point) self.prevPoint = point if not self.boundedMoveShape(shape, point - offset): self.boundedMoveShape(shape, point + offset) def paintEvent(self, event): if not self.pixmap: return super(Canvas, self).paintEvent(event) p = self._painter p.begin(self) p.setRenderHint(QPainter.Antialiasing) p.setRenderHint(QPainter.HighQualityAntialiasing) p.setRenderHint(QPainter.SmoothPixmapTransform) p.scale(self.scale, self.scale) p.translate(self.offsetToCenter()) p.drawPixmap(0, 0, self.pixmap) Shape.scale = self.scale for shape in self.shapes: if (shape.selected or not self._hideBackround) and self.isVisible(shape): shape.fill = shape.selected or shape == self.hShape shape.paint(p) if self.current: self.current.paint(p) self.line.paint(p) if self.selectedShapeCopy: self.selectedShapeCopy.paint(p) # Paint rect if self.current is not None and len(self.line) == 2: leftTop = self.line[0] rightBottom = self.line[1] rectWidth = rightBottom.x() - leftTop.x() rectHeight = rightBottom.y() - leftTop.y() color = QColor(0, 220, 0) p.setPen(color) brush = QBrush(Qt.BDiagPattern) p.setBrush(brush) p.drawRect(leftTop.x(), leftTop.y(), rectWidth, rectHeight) p.end() def transformPos(self, point): """Convert from widget-logical coordinates to painter-logical coordinates.""" return point / self.scale - self.offsetToCenter() def offsetToCenter(self): s = self.scale area = super(Canvas, self).size() w, h = self.pixmap.width() * s, self.pixmap.height() * s aw, ah = area.width(), area.height() x = (aw - w) / (2 * s) if aw > w else 0 y = (ah - h) / (2 * s) if ah > h else 0 return QPointF(x, y) def outOfPixmap(self, p): w, h = self.pixmap.width(), self.pixmap.height() return not (0 <= p.x() <= w and 0 <= p.y() <= h) def finalise(self): assert self.current self.current.close() self.shapes.append(self.current) self.current = None self.setHiding(False) self.newShape.emit() self.update() def closeEnough(self, p1, p2): #d = distance(p1 - p2) #m = (p1-p2).manhattanLength() #print "d %.2f, m %d, %.2f" % (d, m, d - m) return distance(p1 - p2) < self.epsilon def intersectionPoint(self, p1, p2): # Cycle through each image edge in clockwise fashion, # and find the one intersecting the current line segment. # http://paulbourke.net/geometry/lineline2d/ size = self.pixmap.size() points = [(0, 0), (size.width(), 0), (size.width(), size.height()), (0, size.height())] x1, y1 = p1.x(), p1.y() x2, y2 = p2.x(), p2.y() d, i, (x, y) = min(self.intersectingEdges((x1, y1), (x2, y2), points)) x3, y3 = points[i] x4, y4 = points[(i + 1) % 4] if (x, y) == (x1, y1): # Handle cases where previous point is on one of the edges. if x3 == x4: return QPointF(x3, min(max(0, y2), max(y3, y4))) else: # y3 == y4 return QPointF(min(max(0, x2), max(x3, x4)), y3) return QPointF(x, y) def intersectingEdges(self, x1y1, x2y2, points): """For each edge formed by `points', yield the intersection with the line segment `(x1,y1) - (x2,y2)`, if it exists. Also return the distance of `(x2,y2)' to the middle of the edge along with its index, so that the one closest can be chosen.""" x1, y1 = x1y1 x2, y2 = x2y2 for i in range(4): x3, y3 = points[i] x4, y4 = points[(i + 1) % 4] denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1) nua = (x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3) nub = (x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3) if denom == 0: # This covers two cases: # nua == nub == 0: Coincident # otherwise: Parallel continue ua, ub = nua / denom, nub / denom if 0 <= ua <= 1 and 0 <= ub <= 1: x = x1 + ua * (x2 - x1) y = y1 + ua * (y2 - y1) m = QPointF((x3 + x4) / 2, (y3 + y4) / 2) d = distance(m - QPointF(x2, y2)) yield d, i, (x, y) # These two, along with a call to adjustSize are required for the # scroll area. def sizeHint(self): return self.minimumSizeHint() def minimumSizeHint(self): if self.pixmap: return self.scale * self.pixmap.size() return super(Canvas, self).minimumSizeHint() def wheelEvent(self, ev): if ev.orientation() == Qt.Vertical: mods = ev.modifiers() if Qt.ControlModifier == int(mods): self.zoomRequest.emit(ev.delta()) else: self.scrollRequest.emit(ev.delta(), Qt.Horizontal if (Qt.ShiftModifier == int(mods))\ else Qt.Vertical) else: self.scrollRequest.emit(ev.delta(), Qt.Horizontal) ev.accept() def keyPressEvent(self, ev): key = ev.key() if key == Qt.Key_Escape and self.current: print('ESC press') self.current = None self.drawingPolygon.emit(False) self.update() elif key == Qt.Key_Return and self.canCloseShape(): self.finalise() def setLastLabel(self, text): assert text self.shapes[-1].label = text return self.shapes[-1] def undoLastLine(self): assert self.shapes self.current = self.shapes.pop() self.current.setOpen() self.line.points = [self.current[-1], self.current[0]] self.drawingPolygon.emit(True) def resetAllLines(self): assert self.shapes self.current = self.shapes.pop() self.current.setOpen() self.line.points = [self.current[-1], self.current[0]] self.drawingPolygon.emit(True) self.current = None self.drawingPolygon.emit(False) self.update() def loadPixmap(self, pixmap): self.pixmap = pixmap self.shapes = [] self.repaint() def loadShapes(self, shapes): self.shapes = list(shapes) self.current = None self.repaint() def setShapeVisible(self, shape, value): self.visible[shape] = value self.repaint() def overrideCursor(self, cursor): self.restoreCursor() self._cursor = cursor QApplication.setOverrideCursor(cursor) def restoreCursor(self): QApplication.restoreOverrideCursor() def resetState(self): self.restoreCursor() self.pixmap = None self.update()
class Canvas(QWidget): zoomRequest = pyqtSignal(int) scrollRequest = pyqtSignal(int, int) newShape = pyqtSignal() selectionChanged = pyqtSignal(bool) shapeMoved = pyqtSignal() drawingPolygon = pyqtSignal(bool) CREATE, EDIT = range(2) epsilon = 11.0 def __init__(self, *args, **kwargs): super(Canvas, self).__init__(*args, **kwargs) # Initialise local state. self.anno = [] self.mode = self.EDIT self.shapes = [] self.current = None self.selectedShape = None # save the selected shape here self.selectedShapeCopy = None self.lineColor = QColor(0, 0, 255) self.line = Shape(line_color=self.lineColor) self.prevPoint = QPointF() self.offsets = QPointF(), QPointF() self.scale = 1.0 self.pixmap = QPixmap() self.visible = {} self._hideBackround = False self.hideBackround = False self.hShape = None self.hVertex = None self._painter = QPainter() self._cursor = CURSOR_DEFAULT # Menus: self.menus = (QMenu(), QMenu()) # Set widget options. self.setMouseTracking(True) self.setFocusPolicy(Qt.WheelFocus) def enterEvent(self, ev): self.overrideCursor(self._cursor) def leaveEvent(self, ev): self.restoreCursor() def focusOutEvent(self, ev): self.restoreCursor() def isVisible(self, shape): return self.visible.get(shape, True) def drawing(self): return self.mode == self.CREATE def editing(self): return self.mode == self.EDIT def setEditing(self, value=True): self.mode = self.EDIT if value else self.CREATE if not value: # Create self.unHighlight() self.deSelectShape() def unHighlight(self): if self.hShape: self.hShape.highlightClear() self.hVertex = self.hShape = None def selectedVertex(self): return self.hVertex is not None def mouseMoveEvent(self, ev): """Update line with last point and current coordinates.""" pos = self.transformPos(ev.posF()) self.restoreCursor() # Polygon drawing. if self.drawing(): self.overrideCursor(CURSOR_DRAW) if self.current: color = self.lineColor if self.outOfPixmap(pos): # Don't allow the user to draw outside the pixmap. # Project the point to the pixmap's edges. pos = self.intersectionPoint(self.current[-1], pos) elif len(self.current) > 1 and self.closeEnough(pos, self.current[0]): # Attract line to starting point and colorise to alert the user: pos = self.current[0] color = self.current.line_color self.overrideCursor(CURSOR_POINT) self.current.highlightVertex(0, Shape.NEAR_VERTEX) self.line[1] = pos self.line.line_color = color self.repaint() self.current.highlightClear() return # Polygon copy moving. if Qt.RightButton & ev.buttons(): if self.selectedShapeCopy and self.prevPoint: self.overrideCursor(CURSOR_MOVE) self.boundedMoveShape(self.selectedShapeCopy, pos) self.repaint() elif self.selectedShape: self.selectedShapeCopy = self.selectedShape.copy() self.repaint() return # Polygon/Vertex moving. if Qt.LeftButton & ev.buttons(): if self.selectedVertex(): self.boundedMoveVertex(pos) self.shapeMoved.emit() self.repaint() elif self.selectedShape and self.prevPoint: self.overrideCursor(CURSOR_MOVE) self.boundedMoveShape(self.selectedShape, pos) self.shapeMoved.emit() self.repaint() return # Just hovering over the canvas, 2 posibilities: # - Highlight shapes # - Highlight vertex # Update shape/vertex fill and tooltip value accordingly. self.setToolTip("Image") for shape in reversed([s for s in self.shapes if self.isVisible(s)]): # Look for a nearby vertex to highlight. If that fails, # check if we happen to be inside a shape. index = shape.nearestVertex(pos, self.epsilon) if index is not None: if self.selectedVertex(): self.hShape.highlightClear() self.hVertex, self.hShape = index, shape shape.highlightVertex(index, shape.MOVE_VERTEX) self.overrideCursor(CURSOR_POINT) self.setToolTip("Click & drag to move point") self.setStatusTip(self.toolTip()) self.update() break elif shape.containsPoint(pos): if self.selectedVertex(): self.hShape.highlightClear() self.hVertex, self.hShape = None, shape self.setToolTip("Click & drag to move shape '%s'" % shape.label) self.setStatusTip(self.toolTip()) self.overrideCursor(CURSOR_GRAB) self.update() break else: # Nothing found, clear highlights, reset state. if self.hShape: self.hShape.highlightClear() self.update() self.hVertex, self.hShape = None, None def mousePressEvent(self, ev): pos = self.transformPos(ev.posF()) if ev.button() == Qt.LeftButton: if self.drawing(): if self.current: self.current.addPoint(self.line[1]) self.line[0] = self.current[-1] if self.current.isClosed(): self.finalise() elif not self.outOfPixmap(pos): self.current = Shape() self.current.addPoint(pos) self.line.points = [pos, pos] self.setHiding() self.drawingPolygon.emit(True) self.update() else: self.selectShapePoint(pos) self.prevPoint = pos self.repaint() elif ev.button() == Qt.RightButton and self.editing(): self.selectShapePoint(pos) self.prevPoint = pos self.repaint() def mouseReleaseEvent(self, ev): if ev.button() == Qt.RightButton: menu = self.menus[bool(self.selectedShapeCopy)] self.restoreCursor() if not menu.exec_(self.mapToGlobal(ev.pos())) and self.selectedShapeCopy: # Cancel the move by deleting the shadow copy. self.selectedShapeCopy = None self.repaint() elif ev.button() == Qt.LeftButton and self.selectedShape: self.overrideCursor(CURSOR_GRAB) def endMove(self, copy=False): assert self.selectedShape and self.selectedShapeCopy shape = self.selectedShapeCopy # del shape.fill_color # del shape.line_color if copy: self.shapes.append(shape) self.selectedShape.selected = False self.selectedShape = shape self.repaint() else: shape.label = self.selectedShape.label self.deleteSelected() self.shapes.append(shape) self.selectedShapeCopy = None def hideBackroundShapes(self, value): self.hideBackround = value if self.selectedShape: # Only hide other shapes if there is a current selection. # Otherwise the user will not be able to select a shape. self.setHiding(True) self.repaint() def setHiding(self, enable=True): self._hideBackround = self.hideBackround if enable else False def canCloseShape(self): return self.drawing() and self.current and len(self.current) > 2 def mouseDoubleClickEvent(self, ev): # We need at least 4 points here, since the mousePress handler # adds an extra one before this handler is called. if self.canCloseShape() and len(self.current) > 3: self.current.popPoint() self.finalise() def selectShape(self, shape): self.deSelectShape() shape.selected = True self.selectedShape = shape self.setHiding() self.selectionChanged.emit(True) self.update() def selectShapePoint(self, point): """Select the first shape created which contains this point.""" self.deSelectShape() if self.selectedVertex(): # A vertex is marked for selection. index, shape = self.hVertex, self.hShape shape.highlightVertex(index, shape.MOVE_VERTEX) return for shape in reversed(self.shapes): if self.isVisible(shape) and shape.containsPoint(point): shape.selected = True self.selectedShape = shape self.calculateOffsets(shape, point) self.setHiding() self.selectionChanged.emit(True) return def calculateOffsets(self, shape, point): rect = shape.boundingRect() x1 = rect.x() - point.x() y1 = rect.y() - point.y() x2 = (rect.x() + rect.width()) - point.x() y2 = (rect.y() + rect.height()) - point.y() self.offsets = QPointF(x1, y1), QPointF(x2, y2) def boundedMoveVertex(self, pos): index, shape = self.hVertex, self.hShape point = shape[index] if self.outOfPixmap(pos): pos = self.intersectionPoint(point, pos) shape.moveVertexBy(index, pos - point) def boundedMoveShape(self, shape, pos): if self.outOfPixmap(pos): return False # No need to move o1 = pos + self.offsets[0] if self.outOfPixmap(o1): pos -= QPointF(min(0, o1.x()), min(0, o1.y())) o2 = pos + self.offsets[1] if self.outOfPixmap(o2): pos += QPointF(min(0, self.pixmap.width() - o2.x()), min(0, self.pixmap.height() - o2.y())) # The next line tracks the new position of the cursor # relative to the shape, but also results in making it # a bit "shaky" when nearing the border and allows it to # go outside of the shape's area for some reason. XXX # self.calculateOffsets(self.selectedShape, pos) dp = pos - self.prevPoint if dp: shape.moveBy(dp) self.prevPoint = pos return True return False def deSelectShape(self): if self.selectedShape: self.selectedShape.selected = False self.selectedShape = None self.setHiding(False) self.selectionChanged.emit(False) self.update() def deleteSelected(self): if self.selectedShape: shape = self.selectedShape self.shapes.remove(self.selectedShape) self.selectedShape = None self.update() return shape def copySelectedShape(self): if self.selectedShape: shape = self.selectedShape.copy() self.deSelectShape() self.shapes.append(shape) shape.selected = True self.selectedShape = shape self.boundedShiftShape(shape) return shape def boundedShiftShape(self, shape): # Try to move in one direction, and if it fails in another. # Give up if both fail. point = shape[0] offset = QPointF(2.0, 2.0) self.calculateOffsets(shape, point) self.prevPoint = point if not self.boundedMoveShape(shape, point - offset): self.boundedMoveShape(shape, point + offset) def paintEvent(self, event): if not self.pixmap: return super(Canvas, self).paintEvent(event) p = self._painter p.begin(self) p.setRenderHint(QPainter.Antialiasing) p.setRenderHint(QPainter.HighQualityAntialiasing) p.setRenderHint(QPainter.SmoothPixmapTransform) p.scale(self.scale, self.scale) p.translate(self.offsetToCenter()) p.drawPixmap(0, 0, self.pixmap) Shape.scale = self.scale for shape in self.shapes: if (shape.selected or not self._hideBackround) and self.isVisible(shape): shape.fill = shape.selected or shape == self.hShape shape.paint(p) for a in self.anno: for shape in a.shape: shape.line_color = QColor(255, 0, 255, 128) shape.paint(p) if self.current: self.current.paint(p) self.line.paint(p) if self.selectedShapeCopy: self.selectedShapeCopy.paint(p) p.end() def transformPos(self, point): """Convert from widget-logical coordinates to painter-logical coordinates.""" return point / self.scale - self.offsetToCenter() def offsetToCenter(self): s = self.scale area = super(Canvas, self).size() w, h = self.pixmap.width() * s, self.pixmap.height() * s aw, ah = area.width(), area.height() x = (aw - w) / (2 * s) if aw > w else 0 y = (ah - h) / (2 * s) if ah > h else 0 return QPointF(x, y) def outOfPixmap(self, p): w, h = self.pixmap.width(), self.pixmap.height() return not (0 <= p.x() <= w and 0 <= p.y() <= h) def finalise(self): assert self.current self.current.close() self.shapes.append(self.current) self.current = None self.setHiding(False) self.newShape.emit() self.update() def closeEnough(self, p1, p2): # d = distance(p1 - p2) # m = (p1-p2).manhattanLength() # print "d %.2f, m %d, %.2f" % (d, m, d - m) return distance(p1 - p2) < self.epsilon def intersectionPoint(self, p1, p2): # Cycle through each image edge in clockwise fashion, # and find the one intersecting the current line segment. # http://paulbourke.net/geometry/lineline2d/ size = self.pixmap.size() points = [(0, 0), (size.width(), 0), (size.width(), size.height()), (0, size.height())] x1, y1 = p1.x(), p1.y() x2, y2 = p2.x(), p2.y() d, i, (x, y) = min(self.intersectingEdges((x1, y1), (x2, y2), points)) x3, y3 = points[i] x4, y4 = points[(i + 1) % 4] if (x, y) == (x1, y1): # Handle cases where previous point is on one of the edges. if x3 == x4: return QPointF(x3, min(max(0, y2), max(y3, y4))) else: # y3 == y4 return QPointF(min(max(0, x2), max(x3, x4)), y3) return QPointF(x, y) def intersectingEdges(self, (x1, y1), (x2, y2), points): """For each edge formed by `points', yield the intersection with the line segment `(x1,y1) - (x2,y2)`, if it exists. Also return the distance of `(x2,y2)' to the middle of the edge along with its index, so that the one closest can be chosen.""" for i in xrange(4): x3, y3 = points[i] x4, y4 = points[(i + 1) % 4] denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1) nua = (x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3) nub = (x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3) if denom == 0: # This covers two cases: # nua == nub == 0: Coincident # otherwise: Parallel continue ua, ub = nua / denom, nub / denom if 0 <= ua <= 1 and 0 <= ub <= 1: x = x1 + ua * (x2 - x1) y = y1 + ua * (y2 - y1) m = QPointF((x3 + x4) / 2, (y3 + y4) / 2) d = distance(m - QPointF(x2, y2)) yield d, i, (x, y)
class Canvas(QWidget): zoomRequest = pyqtSignal(int) scrollRequest = pyqtSignal(int, int) newShape = pyqtSignal() selectionChanged = pyqtSignal(bool) shapeMoved = pyqtSignal() drawingPolygon = pyqtSignal(bool) hideRRect = pyqtSignal(bool) hideNRect = pyqtSignal(bool) status = pyqtSignal(str) CREATE, EDIT = list(range(2)) epsilon = 11.0 def __init__(self, *args, **kwargs): super(Canvas, self).__init__(*args, **kwargs) # Initialise local state. self.mode = self.EDIT self.shapes = [] self.current = None self.selectedShape = None # save the selected shape here self.selectedShapeCopy = None self.lineColor = QColor(0, 0, 255) self.line = Shape(line_color=self.lineColor) self.prevPoint = QPointF() self.offsets = QPointF(), QPointF() self.scale = 1.0 self.pixmap = QPixmap() self.visible = {} self._hideBackround = False self.hideBackround = False self.hShape = None self.hVertex = None self._painter = QPainter() self._cursor = CURSOR_DEFAULT # Menus: self.menus = (QMenu(), QMenu()) # Set widget options. self.setMouseTracking(True) self.setFocusPolicy(Qt.WheelFocus) self.verified = False # judge can draw rotate rect self.canDrawRotatedRect = True self.hideRotated = False self.hideNormal = False self.canOutOfBounding = False self.showCenter = False def enterEvent(self, ev): self.overrideCursor(self._cursor) def leaveEvent(self, ev): self.restoreCursor() def focusOutEvent(self, ev): self.restoreCursor() def isVisible(self, shape): return self.visible.get(shape, True) def drawing(self): return self.mode == self.CREATE def editing(self): return self.mode == self.EDIT def setEditing(self, value=True): self.mode = self.EDIT if value else self.CREATE if not value: # Create self.unHighlight() self.deSelectShape() def unHighlight(self): if self.hShape: self.hShape.highlightClear() self.hVertex = self.hShape = None def selectedVertex(self): return self.hVertex is not None def mouseMoveEvent(self, ev): """Update line with last point and current coordinates.""" pos = self.transformPos(ev.pos()) self.restoreCursor() # Polygon drawing. if self.drawing(): self.overrideCursor(CURSOR_DRAW) if self.current: color = self.lineColor if self.outOfPixmap(pos): # Don't allow the user to draw outside the pixmap. # Project the point to the pixmap's edges. pos = self.intersectionPoint(self.current[-1], pos) elif len(self.current) > 1 and self.closeEnough( pos, self.current[0]): # Attract line to starting point and colorise to alert the # user: pos = self.current[0] color = self.current.line_color self.overrideCursor(CURSOR_POINT) self.current.highlightVertex(0, Shape.NEAR_VERTEX) #设置宽高比例为 1:10 height = pos.x() - self.line[0].x() width = height * 10 self.line[1] = QPointF(self.line[0].x() + width, self.line[0].y() + height) self.line.line_color = color self.repaint() self.current.highlightClear() self.status.emit("width is %d, height is %d." % (width, height)) return # Polygon copy moving. if Qt.RightButton & ev.buttons(): # print("right button") # if self.selectedShapeCopy and self.prevPoint: # print("select shape copy") # self.overrideCursor(CURSOR_MOVE) # self.boundedMoveShape(self.selectedShapeCopy, pos) # self.repaint() # elif self.selectedShape: # print("select shape") # self.selectedShapeCopy = self.selectedShape.copy() # self.repaint() if self.selectedVertex() and self.selectedShape.isRotated: self.boundedRotateShape(pos) self.shapeMoved.emit() self.repaint() self.status.emit("(%d,%d)." % (pos.x(), pos.y())) return # Polygon/Vertex moving. if Qt.LeftButton & ev.buttons(): if self.selectedVertex(): # if self.outOfPixmap(pos): # print("chule ") # return # else: # print("meiyou chujie") self.boundedMoveVertex(pos) self.shapeMoved.emit() self.repaint() elif self.selectedShape and self.prevPoint: self.overrideCursor(CURSOR_MOVE) self.boundedMoveShape(self.selectedShape, pos) self.shapeMoved.emit() self.repaint() self.status.emit("(%d,%d)." % (pos.x(), pos.y())) return # Just hovering over the canvas, 2 posibilities: # - Highlight shapes # - Highlight vertex # Update shape/vertex fill and tooltip value accordingly. self.setToolTip("Image") for shape in reversed([s for s in self.shapes if self.isVisible(s)]): # Look for a nearby vertex to highlight. If that fails, # check if we happen to be inside a shape. index = shape.nearestVertex(pos, self.epsilon) if index is not None: if self.selectedVertex(): self.hShape.highlightClear() self.hVertex, self.hShape = index, shape shape.highlightVertex(index, shape.MOVE_VERTEX) self.overrideCursor(CURSOR_POINT) # self.setToolTip("Click & drag to move point.") # self.setStatusTip(self.toolTip()) self.update() break elif shape.containsPoint(pos): if self.selectedVertex(): self.hShape.highlightClear() self.hVertex, self.hShape = None, shape # self.setToolTip( # "Click & drag to move shape '%s'" % shape.label) # self.setStatusTip(self.toolTip()) self.overrideCursor(CURSOR_GRAB) self.update() break else: # Nothing found, clear highlights, reset state. if self.hShape: self.hShape.highlightClear() self.update() self.hVertex, self.hShape = None, None self.status.emit("(%d,%d)." % (pos.x(), pos.y())) def mousePressEvent(self, ev): pos = self.transformPos(ev.pos()) # print('sldkfj %d %d' % (pos.x(), pos.y())) if ev.button() == Qt.LeftButton: self.hideBackroundShapes(True) if self.drawing(): self.handleDrawing(pos) else: self.selectShapePoint(pos) self.prevPoint = pos self.repaint() elif ev.button() == Qt.RightButton and self.editing(): self.selectShapePoint(pos) self.hideBackroundShapes(True) # if self.selectedShape is not None: # print('point is (%d, %d)' % (pos.x(), pos.y())) # self.selectedShape.rotate(10) self.prevPoint = pos self.repaint() def mouseReleaseEvent(self, ev): self.hideBackroundShapes(False) if ev.button() == Qt.RightButton and not self.selectedVertex(): menu = self.menus[bool(self.selectedShapeCopy)] self.restoreCursor() if not menu.exec_(self.mapToGlobal(ev.pos()))\ and self.selectedShapeCopy: # Cancel the move by deleting the shadow copy. self.selectedShapeCopy = None self.repaint() elif ev.button() == Qt.LeftButton and self.selectedShape: self.overrideCursor(CURSOR_GRAB) elif ev.button() == Qt.LeftButton: pos = self.transformPos(ev.pos()) if self.drawing(): self.handleDrawing(pos) def endMove(self, copy=False): assert self.selectedShape and self.selectedShapeCopy shape = self.selectedShapeCopy #del shape.fill_color #del shape.line_color if copy: self.shapes.append(shape) self.selectedShape.selected = False self.selectedShape = shape self.repaint() else: self.selectedShape.points = [p for p in shape.points] self.selectedShapeCopy = None def hideBackroundShapes(self, value): # print("hideBackroundShapes") self.hideBackround = value if self.selectedShape: # Only hide other shapes if there is a current selection. # Otherwise the user will not be able to select a shape. self.setHiding(True) self.repaint() def handleDrawing(self, pos): if self.current and self.current.reachMaxPoints() is False: initPos = self.current[0] minX = initPos.x() minY = initPos.y() targetPos = self.line[1] maxX = targetPos.x() maxY = targetPos.y() self.current.addPoint(QPointF(maxX, minY)) self.current.addPoint(targetPos) self.current.addPoint(QPointF(minX, maxY)) self.current.addPoint(initPos) self.line[0] = self.current[-1] if self.current.isClosed(): self.finalise() elif not self.outOfPixmap(pos): self.current = Shape() self.current.addPoint(pos) self.line.points = [pos, pos] self.setHiding() self.drawingPolygon.emit(True) self.update() def setHiding(self, enable=True): self._hideBackround = self.hideBackround if enable else False def canCloseShape(self): return self.drawing() and self.current and len(self.current) > 2 def mouseDoubleClickEvent(self, ev): # We need at least 4 points here, since the mousePress handler # adds an extra one before this handler is called. if self.canCloseShape() and len(self.current) > 3: self.current.popPoint() self.finalise() def selectShape(self, shape): self.deSelectShape() shape.selected = True self.selectedShape = shape self.setHiding() self.selectionChanged.emit(True) self.update() def selectShapePoint(self, point): """Select the first shape created which contains this point.选择创建的第一个包含此点的形状。""" self.deSelectShape() if self.selectedVertex(): # A vertex is marked for selection.标记顶点以供选择。 index, shape = self.hVertex, self.hShape shape.highlightVertex(index, shape.MOVE_VERTEX) shape.selected = True self.selectedShape = shape self.calculateOffsets(shape, point) self.setHiding() self.selectionChanged.emit(True) return for shape in reversed(self.shapes): if self.isVisible(shape) and shape.containsPoint(point): shape.selected = True self.selectedShape = shape self.calculateOffsets(shape, point) self.setHiding() self.selectionChanged.emit(True) return def calculateOffsets(self, shape, point): rect = shape.boundingRect() x1 = rect.x() - point.x() y1 = rect.y() - point.y() x2 = (rect.x() + rect.width()) - point.x() y2 = (rect.y() + rect.height()) - point.y() self.offsets = QPointF(x1, y1), QPointF(x2, y2) def boundedMoveVertex(self, pos): # print("Moving Vertex") index, shape = self.hVertex, self.hShape point = shape[index] if not self.canOutOfBounding and self.outOfPixmap(pos): return # pos = self.intersectionPoint(point, pos) # print("index is %d" % index) sindex = (index + 2) % 4 # get the other 3 points after transformed p2, p3, p4 = self.getAdjointPoints(shape.direction, shape[sindex], pos, index) pcenter = (pos + p3) / 2 if self.canOutOfBounding and self.outOfPixmap(pcenter): return # if one pixal out of map , do nothing if not self.canOutOfBounding and (self.outOfPixmap(p2) or self.outOfPixmap(p3) or self.outOfPixmap(p4)): return # move 4 pixal one by one shape.moveVertexBy(index, pos - point) lindex = (index + 1) % 4 rindex = (index + 3) % 4 shape[lindex] = p2 # shape[sindex] = p3 shape[rindex] = p4 shape.close() # calculate the height and weight, and show it w = math.sqrt((p4.x() - p3.x())**2 + (p4.y() - p3.y())**2) h = math.sqrt((p3.x() - p2.x())**2 + (p3.y() - p2.y())**2) self.status.emit("width is %d, height is %d." % (w, h)) def getAdjointPoints(self, theta, p3, p1, index): # p3 = center # p3 = 2*center-p1 a1 = math.tan(theta) if (a1 == 0): if index % 2 == 0: p2 = QPointF(p3.x(), p1.y()) p4 = QPointF(p1.x(), p3.y()) else: p4 = QPointF(p3.x(), p1.y()) p2 = QPointF(p1.x(), p3.y()) else: a3 = a1 a2 = -1 / a1 a4 = -1 / a1 b1 = p1.y() - a1 * p1.x() b2 = p1.y() - a2 * p1.x() b3 = p3.y() - a1 * p3.x() b4 = p3.y() - a2 * p3.x() if index % 2 == 0: p2 = self.getCrossPoint(a1, b1, a4, b4) p4 = self.getCrossPoint(a2, b2, a3, b3) else: p4 = self.getCrossPoint(a1, b1, a4, b4) p2 = self.getCrossPoint(a2, b2, a3, b3) return p2, p3, p4 def getCrossPoint(self, a1, b1, a2, b2): x = (b2 - b1) / (a1 - a2) y = (a1 * b2 - a2 * b1) / (a1 - a2) return QPointF(x, y) def boundedRotateShape(self, pos): # print("Rotate Shape2") # judge if some vertex is out of pixma index, shape = self.hVertex, self.hShape point = shape[index] angle = self.getAngle(shape.center, pos, point) # for i, p in enumerate(shape.points): # if self.outOfPixmap(shape.rotatePoint(p,angle)): # # print("out of pixmap") # return if not self.rotateOutOfBound(angle): shape.rotate(angle) self.prevPoint = pos def getAngle(self, center, p1, p2): dx1 = p1.x() - center.x() dy1 = p1.y() - center.y() dx2 = p2.x() - center.x() dy2 = p2.y() - center.y() c = math.sqrt(dx1 * dx1 + dy1 * dy1) * math.sqrt(dx2 * dx2 + dy2 * dy2) if c == 0: return 0 y = (dx1 * dx2 + dy1 * dy2) / c if y > 1: return 0 angle = math.acos(y) if (dx1 * dy2 - dx2 * dy1) > 0: return angle else: return -angle def boundedMoveShape(self, shape, pos): if shape.isRotated and self.canOutOfBounding: c = shape.center dp = pos - self.prevPoint dc = c + dp if dc.x() < 0: dp -= QPointF(min(0, dc.x()), 0) if dc.y() < 0: dp -= QPointF(0, min(0, dc.y())) if dc.x() >= self.pixmap.width(): dp += QPointF(min(0, self.pixmap.width() - 1 - dc.x()), 0) if dc.y() >= self.pixmap.height(): dp += QPointF(0, min(0, self.pixmap.height() - 1 - dc.y())) else: if self.outOfPixmap(pos): return False # No need to move o1 = pos + self.offsets[0] if self.outOfPixmap(o1): pos -= QPointF(min(0, o1.x()), min(0, o1.y())) o2 = pos + self.offsets[1] if self.outOfPixmap(o2): pos += QPointF(min(0, self.pixmap.width() - 1 - o2.x()), min(0, self.pixmap.height() - 1 - o2.y())) dp = pos - self.prevPoint # The next line tracks the new position of the cursor # relative to the shape, but also results in making it # a bit "shaky" when nearing the border and allows it to # go outside of the shape's area for some reason. XXX #self.calculateOffsets(self.selectedShape, pos) if dp: shape.moveBy(dp) self.prevPoint = pos shape.close() return True return False def boundedMoveShape2(self, shape, pos): if self.outOfPixmap(pos): return False # No need to move o1 = pos + self.offsets[0] if self.outOfPixmap(o1): pos -= QPointF(min(0, o1.x()), min(0, o1.y())) o2 = pos + self.offsets[1] if self.outOfPixmap(o2): pos += QPointF(min(0, self.pixmap.width() - o2.x()), min(0, self.pixmap.height() - o2.y())) # The next line tracks the new position of the cursor # relative to the shape, but also results in making it # a bit "shaky" when nearing the border and allows it to # go outside of the shape's area for some reason. XXX #self.calculateOffsets(self.selectedShape, pos) dp = pos - self.prevPoint if dp: shape.moveBy(dp) self.prevPoint = pos shape.close() return True return False def deSelectShape(self): if self.selectedShape: self.selectedShape.selected = False self.selectedShape = None self.setHiding(False) self.selectionChanged.emit(False) self.update() def deleteSelected(self): if self.selectedShape: shape = self.selectedShape self.shapes.remove(self.selectedShape) self.selectedShape = None self.update() return shape def copySelectedShape(self): if self.selectedShape: shape = self.selectedShape.copy() self.deSelectShape() self.shapes.append(shape) shape.selected = True self.selectedShape = shape self.boundedShiftShape(shape) return shape def boundedShiftShape(self, shape): # Try to move in one direction, and if it fails in another. # Give up if both fail. point = shape[0] offset = QPointF(2.0, 2.0) self.calculateOffsets(shape, point) self.prevPoint = point if not self.boundedMoveShape(shape, point - offset): self.boundedMoveShape(shape, point + offset) def paintEvent(self, event): if not self.pixmap: return super(Canvas, self).paintEvent(event) p = self._painter p.begin(self) p.setRenderHint(QPainter.Antialiasing) p.setRenderHint(QPainter.HighQualityAntialiasing) p.setRenderHint(QPainter.SmoothPixmapTransform) p.scale(self.scale, self.scale) p.translate(self.offsetToCenter()) p.drawPixmap(0, 0, self.pixmap) Shape.scale = self.scale for shape in self.shapes: if (shape.selected or not self._hideBackround) and self.isVisible(shape): if (shape.isRotated and not self.hideRotated) or (not shape.isRotated and not self.hideNormal): shape.fill = shape.selected or shape == self.hShape shape.paint(p) elif self.showCenter: shape.fill = shape.selected or shape == self.hShape shape.paintNormalCenter(p) if self.current: self.current.paint(p) self.line.paint(p) if self.selectedShapeCopy: self.selectedShapeCopy.paint(p) # Paint rect if self.current is not None and len(self.line) == 2: leftTop = self.line[0] rightBottom = self.line[1] rectWidth = rightBottom.x() - leftTop.x() rectHeight = rightBottom.y() - leftTop.y() color = QColor(0, 220, 0) p.setPen(color) brush = QBrush(Qt.BDiagPattern) p.setBrush(brush) p.drawRect(leftTop.x(), leftTop.y(), rectWidth, rectHeight) #draw dialog line of rectangle p.setPen(self.lineColor) p.drawLine(leftTop.x(), rightBottom.y(), rightBottom.x(), leftTop.y()) self.setAutoFillBackground(True) if self.verified: pal = self.palette() pal.setColor(self.backgroundRole(), QColor(184, 239, 38, 128)) self.setPalette(pal) else: pal = self.palette() pal.setColor(self.backgroundRole(), QColor(232, 232, 232, 255)) self.setPalette(pal) p.end() def transformPos(self, point): """Convert from widget-logical coordinates to painter-logical coordinates.""" return point / self.scale - self.offsetToCenter() def offsetToCenter(self): s = self.scale area = super(Canvas, self).size() w, h = self.pixmap.width() * s, self.pixmap.height() * s aw, ah = area.width(), area.height() x = (aw - w) / (2 * s) if aw > w else 0 y = (ah - h) / (2 * s) if ah > h else 0 return QPointF(x, y) def outOfPixmap(self, p): w, h = self.pixmap.width(), self.pixmap.height() return not (0 <= p.x() < w and 0 <= p.y() < h) def finalise(self): assert self.current self.current.isRotated = self.canDrawRotatedRect # print(self.canDrawRotatedRect) self.current.close() self.shapes.append(self.current) self.current = None self.setHiding(False) self.newShape.emit() self.update() def closeEnough(self, p1, p2): #d = distance(p1 - p2) #m = (p1-p2).manhattanLength() # print "d %.2f, m %d, %.2f" % (d, m, d - m) return distance(p1 - p2) < self.epsilon def intersectionPoint(self, p1, p2): # Cycle through each image edge in clockwise fashion, # and find the one intersecting the current line segment. # http://paulbourke.net/geometry/lineline2d/ size = self.pixmap.size() points = [(0, 0), (size.width(), 0), (size.width(), size.height()), (0, size.height())] x1, y1 = p1.x(), p1.y() x2, y2 = p2.x(), p2.y() d, i, (x, y) = min(self.intersectingEdges((x1, y1), (x2, y2), points)) x3, y3 = points[i] x4, y4 = points[(i + 1) % 4] if (x, y) == (x1, y1): # Handle cases where previous point is on one of the edges. if x3 == x4: return QPointF(x3, min(max(0, y2), max(y3, y4))) else: # y3 == y4 return QPointF(min(max(0, x2), max(x3, x4)), y3) return QPointF(x, y) def intersectingEdges(self, x1y1, x2y2, points): """For each edge formed by `points', yield the intersection with the line segment `(x1,y1) - (x2,y2)`, if it exists. Also return the distance of `(x2,y2)' to the middle of the edge along with its index, so that the one closest can be chosen.""" x1, y1 = x1y1 x2, y2 = x2y2 for i in range(4): x3, y3 = points[i] x4, y4 = points[(i + 1) % 4] denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1) nua = (x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3) nub = (x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3) if denom == 0: # This covers two cases: # nua == nub == 0: Coincident # otherwise: Parallel continue ua, ub = nua / denom, nub / denom if 0 <= ua <= 1 and 0 <= ub <= 1: x = x1 + ua * (x2 - x1) y = y1 + ua * (y2 - y1) m = QPointF((x3 + x4) / 2, (y3 + y4) / 2) d = distance(m - QPointF(x2, y2)) print("return=", d, i, (x, y)) yield d, i, (x, y) # These two, along with a call to adjustSize are required for the # scroll area. def sizeHint(self): return self.minimumSizeHint() def minimumSizeHint(self): if self.pixmap: return self.scale * self.pixmap.size() return super(Canvas, self).minimumSizeHint() def wheelEvent(self, ev): qt_version = 4 if hasattr(ev, "delta") else 5 if qt_version == 4: if ev.orientation() == Qt.Vertical: v_delta = ev.delta() h_delta = 0 else: h_delta = ev.delta() v_delta = 0 else: delta = ev.angleDelta() h_delta = delta.x() v_delta = delta.y() # print('scrolling vdelta is %d, hdelta is %d' % (v_delta, h_delta)) mods = ev.modifiers() if Qt.ControlModifier == int(mods) and v_delta: self.zoomRequest.emit(v_delta) else: v_delta and self.scrollRequest.emit(v_delta, Qt.Vertical) h_delta and self.scrollRequest.emit(h_delta, Qt.Horizontal) ev.accept() def keyPressEvent(self, ev): key = ev.key() if key == Qt.Key_Escape and self.current: print('ESC press') self.current = None self.drawingPolygon.emit(False) self.update() elif key == Qt.Key_Return and self.canCloseShape(): self.finalise() elif key == Qt.Key_Left and self.selectedShape: self.moveOnePixel('Left') elif key == Qt.Key_Right and self.selectedShape: self.moveOnePixel('Right') elif key == Qt.Key_Up and self.selectedShape: self.moveOnePixel('Up') elif key == Qt.Key_Down and self.selectedShape: self.moveOnePixel('Down') elif key == Qt.Key_Z and self.selectedShape and\ self.selectedShape.isRotated and not self.rotateOutOfBound(0.1): self.selectedShape.rotate(0.1) self.shapeMoved.emit() self.update() elif key == Qt.Key_X and self.selectedShape and\ self.selectedShape.isRotated and not self.rotateOutOfBound(0.01): self.selectedShape.rotate(0.01) self.shapeMoved.emit() self.update() elif key == Qt.Key_C and self.selectedShape and\ self.selectedShape.isRotated and not self.rotateOutOfBound(-0.01): self.selectedShape.rotate(-0.01) self.shapeMoved.emit() self.update() elif key == Qt.Key_V and self.selectedShape and\ self.selectedShape.isRotated and not self.rotateOutOfBound(-0.1): self.selectedShape.rotate(-0.1) self.shapeMoved.emit() self.update() elif key == Qt.Key_R: self.hideRotated = not self.hideRotated self.hideRRect.emit(self.hideRotated) self.update() elif key == Qt.Key_N: self.hideNormal = not self.hideNormal self.hideNRect.emit(self.hideNormal) self.update() elif key == Qt.Key_O: self.canOutOfBounding = not self.canOutOfBounding elif key == Qt.Key_B: self.showCenter = not self.showCenter self.update() def rotateOutOfBound(self, angle): if self.canOutOfBounding: return False for i, p in enumerate(self.selectedShape.points): if self.outOfPixmap(self.selectedShape.rotatePoint(p, angle)): return True return False def moveOnePixel(self, direction): # print(self.selectedShape.points) if direction == 'Left' and not self.moveOutOfBound(QPointF(-1.0, 0)): # print("move Left one pixel") self.selectedShape.points[0] += QPointF(-1.0, 0) self.selectedShape.points[1] += QPointF(-1.0, 0) self.selectedShape.points[2] += QPointF(-1.0, 0) self.selectedShape.points[3] += QPointF(-1.0, 0) self.selectedShape.center += QPointF(-1.0, 0) elif direction == 'Right' and not self.moveOutOfBound(QPointF(1.0, 0)): # print("move Right one pixel") self.selectedShape.points[0] += QPointF(1.0, 0) self.selectedShape.points[1] += QPointF(1.0, 0) self.selectedShape.points[2] += QPointF(1.0, 0) self.selectedShape.points[3] += QPointF(1.0, 0) self.selectedShape.center += QPointF(1.0, 0) elif direction == 'Up' and not self.moveOutOfBound(QPointF(0, -1.0)): # print("move Up one pixel") self.selectedShape.points[0] += QPointF(0, -1.0) self.selectedShape.points[1] += QPointF(0, -1.0) self.selectedShape.points[2] += QPointF(0, -1.0) self.selectedShape.points[3] += QPointF(0, -1.0) self.selectedShape.center += QPointF(0, -1.0) elif direction == 'Down' and not self.moveOutOfBound(QPointF(0, 1.0)): # print("move Down one pixel") self.selectedShape.points[0] += QPointF(0, 1.0) self.selectedShape.points[1] += QPointF(0, 1.0) self.selectedShape.points[2] += QPointF(0, 1.0) self.selectedShape.points[3] += QPointF(0, 1.0) self.selectedShape.center += QPointF(0, 1.0) self.shapeMoved.emit() self.repaint() def moveOutOfBound(self, step): points = [ p1 + p2 for p1, p2 in zip(self.selectedShape.points, [step] * 4) ] return True in map(self.outOfPixmap, points) def setLastLabel(self, text): assert text self.shapes[-1].label = text return self.shapes[-1] def undoLastLine(self): assert self.shapes self.current = self.shapes.pop() self.current.setOpen() self.line.points = [self.current[-1], self.current[0]] self.drawingPolygon.emit(True) def resetAllLines(self): assert self.shapes self.current = self.shapes.pop() self.current.setOpen() self.line.points = [self.current[-1], self.current[0]] self.drawingPolygon.emit(True) self.current = None self.drawingPolygon.emit(False) self.update() def loadPixmap(self, pixmap): self.pixmap = pixmap self.shapes = [] self.repaint() def loadShapes(self, shapes): self.shapes = list(shapes) self.current = None self.repaint() def setShapeVisible(self, shape, value): self.visible[shape] = value self.repaint() def overrideCursor(self, cursor): self.restoreCursor() self._cursor = cursor QApplication.setOverrideCursor(cursor) def restoreCursor(self): QApplication.restoreOverrideCursor() def resetState(self): self.restoreCursor() self.pixmap = None self.update()
class Canvas(QWidget): zoomRequest = pyqtSignal(int) #pyqtSignal自定义信号 scrollRequest = pyqtSignal(int, int) newShape = pyqtSignal() selectionChanged = pyqtSignal(bool) shapeMoved = pyqtSignal() drawingPolygon = pyqtSignal(bool) hideRRect = pyqtSignal(bool) hideNRect = pyqtSignal(bool) status = pyqtSignal(str) CREATE, EDIT = list(range(2)) epsilon = 11.0 def __init__(self, *args, **kwargs): super(Canvas, self).__init__(*args, **kwargs) # Initialise local state. self.mode = self.EDIT self.shapes = [] self.current = None self.selectedShape = None # save the selected shape here self.selectedShapeCopy = None self.lineColor = QColor(0, 255, 0) self.line = Shape(line_color=self.lineColor) self.prevPoint = QPointF() self.offsets = QPointF(), QPointF() self.scale = 1.0 self.pixmap = QPixmap() self.visible = {} self._hideBackround = False self.hideBackround = False self.hShape = None self.hVertex = None self._painter = QPainter() self._cursor = CURSOR_DEFAULT #鼠标形状初始化为默认形状 # Menus: self.menus = (QMenu(), QMenu()) # Set widget options. self.setMouseTracking(True) self.setFocusPolicy(Qt.WheelFocus) #通过鼠标滚轮来获取焦点事件 self.verified = False # judge can draw rotate rect self.canDrawRotatedRect = True self.hideRotated = False self.hideNormal = False self.canOutOfBounding = False self.showCenter = False #通过光标状态启动事件 def enterEvent(self, ev): self.overrideCursor(self._cursor) #弹出光标退出事件 def leaveEvent(self, ev): self.restoreCursor() #弹出光标离焦事件 def focusOutEvent(self, ev): self.restoreCursor() #判断当前标注框是否可见(若该标注框不存在于字典中,则返回默认值True) def isVisible(self, shape): return self.visible.get( shape, True) #字典(Dictionary) get() 函数返回指定键的值,如果值不在字典中返回默认值 #绘制时mode为Create def drawing(self): return self.mode == self.CREATE #编辑时mode为Edit def editing(self): return self.mode == self.EDIT def setEditing(self, value=True): self.mode = self.EDIT if value else self.CREATE if not value: # Create self.unHighlight() self.deSelectShape() def unHighlight(self): if self.hShape: self.hShape.highlightClear() self.hVertex = self.hShape = None #返回选中的顶点 def selectedVertex(self): return self.hVertex is not None def mouseMoveEvent(self, ev): """Update line with last point and current coordinates.""" pos = self.transformPos(ev.pos()) #获取当前鼠标位置在图像坐标系下的坐标 self.restoreCursor() # Polygon drawing. if self.drawing(): self.overrideCursor(CURSOR_DRAW) #修改鼠标形状为绘制状态 if self.current: color = self.lineColor if self.outOfPixmap(pos): #当鼠标位置超出图像区域时,选取与图像四边最近的交点作为当前鼠标位置 # Don't allow the user to draw outside the pixmap. # Project the point to the pixmap's edges. pos = self.intersectionPoint(self.current[-1], pos) elif len(self.current) > 1 and self.closeEnough( pos, self.current[0]): # Attract line to starting point and colorise to alert the # user: 吸引线到起点并着色以提醒用户 pos = self.current[0] color = self.current.line_color self.overrideCursor(CURSOR_POINT) self.current.highlightVertex(0, Shape.NEAR_VERTEX) self.line[1] = pos self.line.line_color = color self.repaint() self.current.highlightClear() self.status.emit("width is %d, height is %d." % (pos.x() - self.line[0].x(), pos.y() - self.line[0].y())) #在状态栏中显示当前矩形框的长和宽 return # Polygon copy moving. 当鼠标右键按下且满足旋转条件时执行旋转操作 if Qt.RightButton & ev.buttons(): # print("right button") # if self.selectedShapeCopy and self.prevPoint: # print("select shape copy") # self.overrideCursor(CURSOR_MOVE) # self.boundedMoveShape(self.selectedShapeCopy, pos) # self.repaint() # elif self.selectedShape: # print("select shape") # self.selectedShapeCopy = self.selectedShape.copy() # self.repaint() if self.selectedVertex( ) and self.selectedShape.isRotated: #如果选中了某一顶点且该矩形框是有向框,则执行旋转 self.boundedRotateShape(pos) #执行旋转 self.shapeMoved.emit() self.repaint() self.status.emit("(%d,%d)." % (pos.x(), pos.y())) return # Polygon/Vertex moving. 当鼠标左键按下时,响应移动某一顶点或移动整个矩形框 if Qt.LeftButton & ev.buttons(): if self.selectedVertex(): # if self.outOfPixmap(pos): # print("chule ") # return # else: # print("meiyou chujie") self.boundedMoveVertex(pos) #计算出某点移动后的矩形框 self.shapeMoved.emit() self.repaint() elif self.selectedShape and self.prevPoint: self.overrideCursor(CURSOR_MOVE) #改变光标形状 self.boundedMoveShape(self.selectedShape, pos) #计算整个矩形框平移后的坐标值 self.shapeMoved.emit() self.repaint() self.status.emit("(%d,%d)." % (pos.x(), pos.y())) return # Just hovering over the canvas, 2 posibilities: # - Highlight shapes # - Highlight vertex # Update shape/vertex fill and tooltip value accordingly. self.setToolTip("Image") for shape in reversed([s for s in self.shapes if self.isVisible(s)]): # Look for a nearby vertex to highlight. If that fails, # check if we happen to be inside a shape. index = shape.nearestVertex(pos, self.epsilon) #取出最为接近的顶点索引号 if index is not None: if self.selectedVertex(): self.hShape.highlightClear() self.hVertex, self.hShape = index, shape shape.highlightVertex(index, shape.MOVE_VERTEX) self.overrideCursor(CURSOR_POINT) #改变光标形状 # self.setToolTip("Click & drag to move point.") # self.setStatusTip(self.toolTip()) self.update() break elif shape.containsPoint(pos): #如果该点落在了另一标注框内 if self.selectedVertex(): self.hShape.highlightClear() self.hVertex, self.hShape = None, shape # self.setToolTip( # "Click & drag to move shape '%s'" % shape.label) # self.setStatusTip(self.toolTip()) self.overrideCursor(CURSOR_GRAB) self.update() break else: # Nothing found, clear highlights, reset state. 其余情况下,不做任何相关响应 if self.hShape: self.hShape.highlightClear() self.update() self.hVertex, self.hShape = None, None self.status.emit("(%d,%d)." % (pos.x(), pos.y())) #状态栏实时显示当前鼠标光标位置(在图像坐标系下) #响应鼠标左右键按下 def mousePressEvent(self, ev): pos = self.transformPos(ev.pos()) #获取当前鼠标光标位置 # print('sldkfj %d %d' % (pos.x(), pos.y())) if ev.button() == Qt.LeftButton: #如果是左键按下 self.hideBackroundShapes(True) if self.drawing(): self.handleDrawing(pos) #绘制标注框,并保存标注框信息 else: self.selectShapePoint(pos) self.prevPoint = pos self.repaint() elif ev.button() == Qt.RightButton and self.editing(): self.selectShapePoint(pos) self.hideBackroundShapes(True) # if self.selectedShape is not None: # print('point is (%d, %d)' % (pos.x(), pos.y())) # self.selectedShape.rotate(10) self.prevPoint = pos self.repaint() #响应鼠标左右键释放 def mouseReleaseEvent(self, ev): self.hideBackroundShapes(False) if ev.button() == Qt.RightButton and not self.selectedVertex(): #右键释放时 menu = self.menus[bool(self.selectedShapeCopy)] self.restoreCursor() if not menu.exec_(self.mapToGlobal(ev.pos()))\ and self.selectedShapeCopy: # Cancel the move by deleting the shadow copy. self.selectedShapeCopy = None self.repaint() elif ev.button() == Qt.LeftButton and self.selectedShape: #左键释放时 self.overrideCursor(CURSOR_GRAB) #改变光标形状 elif ev.button() == Qt.LeftButton: pos = self.transformPos(ev.pos()) if self.drawing(): self.handleDrawing(pos) #根据copy选择复制或是移动 def endMove(self, copy=False): assert self.selectedShape and self.selectedShapeCopy shape = self.selectedShapeCopy #del shape.fill_color #del shape.line_color if copy: self.shapes.append(shape) self.selectedShape.selected = False self.selectedShape = shape self.repaint() else: self.selectedShape.points = [p for p in shape.points] self.selectedShapeCopy = None #如果选中某一标注框,则隐藏其余标注框 def hideBackroundShapes(self, value): # print("hideBackroundShapes") self.hideBackround = value if self.selectedShape: # Only hide other shapes if there is a current selection. # Otherwise the user will not be able to select a shape. self.setHiding(True) self.repaint() #真正的绘制函数 def handleDrawing(self, pos): if self.current and self.current.reachMaxPoints( ) is False: #再压入标注框的其余三个顶点 initPos = self.current[0] minX = initPos.x() minY = initPos.y() targetPos = self.line[1] maxX = targetPos.x() maxY = targetPos.y() self.current.addPoint(QPointF(maxX, minY)) self.current.addPoint(targetPos) self.current.addPoint(QPointF(minX, maxY)) self.current.addPoint( initPos) #加入第一个点的目的是为了判断是否可以关闭当前绘制的标注框,并不会真正加入 self.line[0] = self.current[-1] if self.current.isClosed(): self.finalise() elif not self.outOfPixmap(pos): #先压入标注框的第一个顶点 self.current = Shape() self.current.addPoint(pos) self.line.points = [pos, pos] self.setHiding() self.drawingPolygon.emit(True) self.update() #设置是否隐藏 def setHiding(self, enable=True): self._hideBackround = self.hideBackround if enable else False #判断是否已经完成某标注框的绘制 def canCloseShape(self): return self.drawing() and self.current and len(self.current) > 2 #响应鼠标双击 def mouseDoubleClickEvent(self, ev): # We need at least 4 points here, since the mousePress handler # adds an extra one before this handler is called. if self.canCloseShape() and len(self.current) > 3: self.current.popPoint() self.finalise() #绑定选中关系 def selectShape(self, shape): self.deSelectShape() shape.selected = True self.selectedShape = shape self.setHiding() self.selectionChanged.emit(True) self.update() #根据当前鼠标光标位置来选择标注框,并响应相应的动作 def selectShapePoint(self, point): """Select the first shape created which contains this point.""" self.deSelectShape() #先解除与其余标注框的绑定关系 if self.selectedVertex(): # A vertex is marked for selection. index, shape = self.hVertex, self.hShape shape.highlightVertex(index, shape.MOVE_VERTEX) shape.selected = True self.selectedShape = shape self.calculateOffsets(shape, point) #计算偏移量 self.setHiding() self.selectionChanged.emit(True) return for shape in reversed(self.shapes): #reversed返回一个反转的迭代器 if self.isVisible(shape) and shape.containsPoint(point): shape.selected = True self.selectedShape = shape self.calculateOffsets(shape, point) self.setHiding() self.selectionChanged.emit(True) return #计算标注框左上角和右下角相对于point的偏移量 def calculateOffsets(self, shape, point): rect = shape.boundingRect() x1 = rect.x() - point.x() y1 = rect.y() - point.y() x2 = (rect.x() + rect.width()) - point.x() y2 = (rect.y() + rect.height()) - point.y() self.offsets = QPointF(x1, y1), QPointF(x2, y2) #计算标注框某一顶点移动后其余三点的坐标值 def boundedMoveVertex(self, pos): # print("Moving Vertex") index, shape = self.hVertex, self.hShape point = shape[index] if not self.canOutOfBounding and self.outOfPixmap(pos): return # pos = self.intersectionPoint(point, pos) # print("index is %d" % index) sindex = (index + 2) % 4 #获取该点对应的对角线上的顶点的索引号 # get the other 3 points after transformed p2, p3, p4 = self.getAdjointPoints(shape.direction, shape[sindex], pos, index) #计算出某一顶点移动后其余三个顶点的坐标值 pcenter = (pos + p3) / 2 #计算矩形框中心坐标 if self.canOutOfBounding and self.outOfPixmap(pcenter): return # if one pixal out of map , do nothing if not self.canOutOfBounding and (self.outOfPixmap(p2) or self.outOfPixmap(p3) or self.outOfPixmap(p4)): return # move 4 pixal one by one 按原来的顺序调整移动后的顶点坐标 shape.moveVertexBy(index, pos - point) lindex = (index + 1) % 4 rindex = (index + 3) % 4 shape[lindex] = p2 # shape[sindex] = p3 shape[rindex] = p4 shape.close() # calculate the height and weight, and show it 此处计算出来的长和宽是有问题的,没有考虑顺序问题,当然对于标注来说没啥问题 w = math.sqrt((p4.x() - p3.x())**2 + (p4.y() - p3.y())**2) h = math.sqrt((p3.x() - p2.x())**2 + (p3.y() - p2.y())**2) self.status.emit("width is %d, height is %d." % (w, h)) #通过几何学求出其余三点坐标值 def getAdjointPoints(self, theta, p3, p1, index): # p3 = center # p3 = 2*center-p1 a1 = math.tan(theta) #无需担心90/270度时,正切值趋于无穷大(math本身进行了处理) if (a1 == 0): #当为无向框或有向框角度为0时 if index % 2 == 0: p2 = QPointF(p3.x(), p1.y()) #只需改变y坐标 p4 = QPointF(p1.x(), p3.y()) #只需改变x坐标 else: p4 = QPointF(p3.x(), p1.y()) #只需改变y坐标 p2 = QPointF(p1.x(), p3.y()) #只需改变x坐标 else: #当为有向框且角度不为0时(以下通过几何学求出) a3 = a1 a2 = -1 / a1 a4 = -1 / a1 b1 = p1.y() - a1 * p1.x() b2 = p1.y() - a2 * p1.x() b3 = p3.y() - a1 * p3.x() b4 = p3.y() - a2 * p3.x() if index % 2 == 0: p2 = self.getCrossPoint(a1, b1, a4, b4) p4 = self.getCrossPoint(a2, b2, a3, b3) else: p4 = self.getCrossPoint(a1, b1, a4, b4) p2 = self.getCrossPoint(a2, b2, a3, b3) return p2, p3, p4 def getCrossPoint(self, a1, b1, a2, b2): x = (b2 - b1) / (a1 - a2) y = (a1 * b2 - a2 * b1) / (a1 - a2) return QPointF(x, y) #判断旋转后的标注矩形框是否完整在图像区域内,如果不完整,则此旋转无效 def boundedRotateShape(self, pos): # print("Rotate Shape2") # judge if some vertex is out of pixma index, shape = self.hVertex, self.hShape point = shape[index] angle = self.getAngle(shape.center, pos, point) #获取旋转角度 # for i, p in enumerate(shape.points): # if self.outOfPixmap(shape.rotatePoint(p,angle)): # # print("out of pixmap") # return if not self.rotateOutOfBound(angle): #只有旋转后完整在图像区域内,才执行旋转 shape.rotate(angle) #执行旋转变换 self.prevPoint = pos def getAngle(self, center, p1, p2): dx1 = p1.x() - center.x() dy1 = p1.y() - center.y() dx2 = p2.x() - center.x() dy2 = p2.y() - center.y() c = math.sqrt(dx1 * dx1 + dy1 * dy1) * math.sqrt(dx2 * dx2 + dy2 * dy2) if c == 0: return 0 y = (dx1 * dx2 + dy1 * dy2) / c #计算两个向量间的余弦值 if y > 1: return 0 angle = math.acos(y) #得到的是弧度值 if (dx1 * dy2 - dx2 * dy1) > 0: return angle else: return -angle #计算平移后的标注框位置 def boundedMoveShape(self, shape, pos): if shape.isRotated and self.canOutOfBounding: c = shape.center dp = pos - self.prevPoint dc = c + dp if dc.x() < 0: dp -= QPointF(min(0, dc.x()), 0) if dc.y() < 0: dp -= QPointF(0, min(0, dc.y())) if dc.x() >= self.pixmap.width(): dp += QPointF(min(0, self.pixmap.width() - 1 - dc.x()), 0) if dc.y() >= self.pixmap.height(): dp += QPointF(0, min(0, self.pixmap.height() - 1 - dc.y())) else: if self.outOfPixmap(pos): return False # No need to move o1 = pos + self.offsets[0] if self.outOfPixmap(o1): pos -= QPointF(min(0, o1.x()), min(0, o1.y())) o2 = pos + self.offsets[1] if self.outOfPixmap(o2): pos += QPointF(min(0, self.pixmap.width() - 1 - o2.x()), min(0, self.pixmap.height() - 1 - o2.y())) dp = pos - self.prevPoint # The next line tracks the new position of the cursor # relative to the shape, but also results in making it # a bit "shaky" when nearing the border and allows it to # go outside of the shape's area for some reason. XXX #self.calculateOffsets(self.selectedShape, pos) if dp: shape.moveBy(dp) #计算平移后的坐标值 self.prevPoint = pos shape.close() return True return False def boundedMoveShape2(self, shape, pos): if self.outOfPixmap(pos): return False # No need to move o1 = pos + self.offsets[0] if self.outOfPixmap(o1): pos -= QPointF(min(0, o1.x()), min(0, o1.y())) o2 = pos + self.offsets[1] if self.outOfPixmap(o2): pos += QPointF(min(0, self.pixmap.width() - o2.x()), min(0, self.pixmap.height() - o2.y())) # The next line tracks the new position of the cursor # relative to the shape, but also results in making it # a bit "shaky" when nearing the border and allows it to # go outside of the shape's area for some reason. XXX #self.calculateOffsets(self.selectedShape, pos) dp = pos - self.prevPoint if dp: shape.moveBy(dp) self.prevPoint = pos shape.close() return True return False #解除选中状态 def deSelectShape(self): if self.selectedShape: self.selectedShape.selected = False self.selectedShape = None self.setHiding(False) #设置为可见 self.selectionChanged.emit(False) self.update() #删除选中的标注框 def deleteSelected(self): if self.selectedShape: shape = self.selectedShape self.shapes.remove(self.selectedShape) self.selectedShape = None self.update() return shape #复制选中的标注框 def copySelectedShape(self): if self.selectedShape: shape = self.selectedShape.copy() self.deSelectShape() self.shapes.append(shape) shape.selected = True self.selectedShape = shape self.boundedShiftShape(shape) return shape #复制标注框时使用,复制的标注框比原始框稍微偏移一小点 def boundedShiftShape(self, shape): # Try to move in one direction, and if it fails in another. # Give up if both fail. point = shape[0] offset = QPointF(2.0, 2.0) self.calculateOffsets(shape, point) #计算偏移量 self.prevPoint = point if not self.boundedMoveShape(shape, point - offset): self.boundedMoveShape(shape, point + offset) #绘制所有 def paintEvent(self, event): if not self.pixmap: return super(Canvas, self).paintEvent(event) p = self._painter p.begin(self) p.setRenderHint(QPainter.Antialiasing) #防锯齿 p.setRenderHint(QPainter.HighQualityAntialiasing) #高质量防锯齿 p.setRenderHint( QPainter.SmoothPixmapTransform) #使用平滑的pixmap变换算法(双线性插值算法) p.scale(self.scale, self.scale) #缩放 p.translate(self.offsetToCenter()) #平移,使得图像中心与画布中心重合 p.drawPixmap(0, 0, self.pixmap) #绘制,(0,0)为要被绘制的绘制设备的左上点 Shape.scale = self.scale for shape in self.shapes: if (shape.selected or not self._hideBackround) and self.isVisible(shape): if (shape.isRotated and not self.hideRotated) or (not shape.isRotated and not self.hideNormal): shape.fill = shape.selected or shape == self.hShape shape.paint(p) #绘制标注框(包括四条边,四个顶点,中心点) elif self.showCenter: shape.fill = shape.selected or shape == self.hShape shape.paintNormalCenter(p) if self.current: self.current.paint(p) self.line.paint(p) if self.selectedShapeCopy: self.selectedShapeCopy.paint(p) # Paint rect if self.current is not None and len(self.line) == 2: leftTop = self.line[0] rightBottom = self.line[1] rectWidth = rightBottom.x() - leftTop.x() rectHeight = rightBottom.y() - leftTop.y() color = QColor(0, 225, 0) p.setPen(color) brush = QBrush(QColor(255, 255, 255), Qt.Dense6Pattern) #画刷 #setBrush() p.setBrush(brush) p.drawRect(leftTop.x(), leftTop.y(), rectWidth, rectHeight) #draw dialog line of rectangle 绘制标注过程中标注框的对角线 p.setPen(self.lineColor) p.drawLine(leftTop.x(), rightBottom.y(), rightBottom.x(), leftTop.y()) self.setAutoFillBackground(True) if self.verified: pal = self.palette() pal.setColor(self.backgroundRole(), QColor(184, 239, 38, 128)) self.setPalette(pal) else: pal = self.palette() pal.setColor(self.backgroundRole(), QColor(232, 232, 232, 255)) self.setPalette(pal) p.end() #从控件坐标系转到图像坐标系 def transformPos(self, point): """Convert from widget-logical coordinates to painter-logical coordinates.""" return point / self.scale - self.offsetToCenter() #函数名字取的不好,其实是图像左上角相对画布左上角的偏移量 #注:图像中心点与画布中心点是重合的 def offsetToCenter(self): s = self.scale area = super(Canvas, self).size() w, h = self.pixmap.width() * s, self.pixmap.height() * s aw, ah = area.width(), area.height() x = (aw - w) / (2 * s) if aw > w else 0 y = (ah - h) / (2 * s) if ah > h else 0 return QPointF(x, y) #判断当前鼠标光标位置p是否在图像区域内 def outOfPixmap(self, p): w, h = self.pixmap.width(), self.pixmap.height() return not (0 <= p.x() < w and 0 <= p.y() < h) #绘制完毕,将当前标注框存入shapes中 def finalise(self): assert self.current self.current.isRotated = self.canDrawRotatedRect # print(self.canDrawRotatedRect) self.current.close() self.shapes.append(self.current) self.current = None self.setHiding(False) self.newShape.emit() self.update() #判断两点是否足够接近 def closeEnough(self, p1, p2): #d = distance(p1 - p2) #m = (p1-p2).manhattanLength() # print "d %.2f, m %d, %.2f" % (d, m, d - m) return distance(p1 - p2) < self.epsilon #以顺时针方式循环每个图像边缘,并找到与当前线段相交的那个 def intersectionPoint(self, p1, p2): # Cycle through each image edge in clockwise fashion, # and find the one intersecting the current line segment. # http://paulbourke.net/geometry/lineline2d/ size = self.pixmap.size() #points存储图像四个顶点坐标 points = [(0, 0), (size.width(), 0), (size.width(), size.height()), (0, size.height())] x1, y1 = p1.x(), p1.y() x2, y2 = p2.x(), p2.y() d, i, (x, y) = min(self.intersectingEdges((x1, y1), (x2, y2), points)) #获取与图像四边最近的交点 x3, y3 = points[i] x4, y4 = points[(i + 1) % 4] if (x, y) == (x1, y1): # Handle cases where previous point is on one of the edges. 处理前一个点在图像边缘上的情况 if x3 == x4: return QPointF(x3, min(max(0, y2), max(y3, y4))) else: # y3 == y4 return QPointF(min(max(0, x2), max(x3, x4)), y3) return QPointF(x, y) #计算出当前直线与图像四条边最近的交点(最近指交点与直线末尾点(x2,y2)的距离最短) def intersectingEdges(self, x1y1, x2y2, points): """For each edge formed by `points', yield the intersection with the line segment `(x1,y1) - (x2,y2)`, if it exists. Also return the distance of `(x2,y2)' to the middle of the edge along with its index, so that the one closest can be chosen.""" x1, y1 = x1y1 x2, y2 = x2y2 for i in range(4): x3, y3 = points[i] x4, y4 = points[(i + 1) % 4] denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1) nua = (x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3) nub = (x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3) if denom == 0: # This covers two cases: # nua == nub == 0: Coincident # otherwise: Parallel continue ua, ub = nua / denom, nub / denom if 0 <= ua <= 1 and 0 <= ub <= 1: x = x1 + ua * (x2 - x1) y = y1 + ua * (y2 - y1) m = QPointF((x3 + x4) / 2, (y3 + y4) / 2) d = distance(m - QPointF(x2, y2)) #print("return=",d,i,(x,y)) yield d, i, (x, y) #返回生成器,可用于迭代 # These two, along with a call to adjustSize are required for the # scroll area. def sizeHint(self): return self.minimumSizeHint() def minimumSizeHint(self): if self.pixmap: return self.scale * self.pixmap.size() return super(Canvas, self).minimumSizeHint() #响应鼠标滚轮 # Modified by Chenbin Zheng, Fix angle error 2018/11/26 def wheelEvent(self, ev): qt_version = 4 if hasattr(ev, "delta") else 5 if qt_version == 4: if ev.orientation() == Qt.Vertical: v_delta = ev.delta() h_delta = 0 else: h_delta = ev.delta() v_delta = 0 else: delta = ev.angleDelta() h_delta = delta.x() v_delta = delta.y() #print('scrolling vdelta is %d, hdelta is %d' % (v_delta, h_delta)) mods = ev.modifiers() ''' if Qt.ControlModifier == int(mods) and v_delta: #当按下Ctrl键时,对图像进行缩放操作 self.zoomRequest.emit(v_delta) else: v_delta and self.scrollRequest.emit(v_delta, Qt.Vertical) h_delta and self.scrollRequest.emit(h_delta, Qt.Horizontal) ''' if Qt.ControlModifier == int(mods): #当按下Ctrl键时,对图像进行缩放操作 v_delta and self.scrollRequest.emit(v_delta, Qt.Vertical) h_delta and self.scrollRequest.emit(h_delta, Qt.Horizontal) elif v_delta: self.zoomRequest.emit(v_delta) ev.accept() #响应键盘按键 def keyPressEvent(self, ev): key = ev.key() if key == Qt.Key_Escape and self.current: print('ESC press') self.current = None self.drawingPolygon.emit(False) self.update() elif key == Qt.Key_Return and self.canCloseShape(): self.finalise() elif key == Qt.Key_Left and self.selectedShape: self.moveOnePixel('Left') elif key == Qt.Key_Right and self.selectedShape: self.moveOnePixel('Right') elif key == Qt.Key_Up and self.selectedShape: self.moveOnePixel('Up') elif key == Qt.Key_Down and self.selectedShape: self.moveOnePixel('Down') elif key == Qt.Key_Z and self.selectedShape and\ self.selectedShape.isRotated and not self.rotateOutOfBound(0.1): self.selectedShape.rotate(0.1) self.shapeMoved.emit() self.update() elif key == Qt.Key_X and self.selectedShape and\ self.selectedShape.isRotated and not self.rotateOutOfBound(0.01): self.selectedShape.rotate(0.01) self.shapeMoved.emit() self.update() elif key == Qt.Key_C and self.selectedShape and\ self.selectedShape.isRotated and not self.rotateOutOfBound(-0.01): self.selectedShape.rotate(-0.01) self.shapeMoved.emit() self.update() elif key == Qt.Key_V and self.selectedShape and\ self.selectedShape.isRotated and not self.rotateOutOfBound(-0.1): self.selectedShape.rotate(-0.1) self.shapeMoved.emit() self.update() elif key == Qt.Key_R: self.hideRotated = not self.hideRotated self.hideRRect.emit(self.hideRotated) self.update() elif key == Qt.Key_N: self.hideNormal = not self.hideNormal self.hideNRect.emit(self.hideNormal) self.update() elif key == Qt.Key_O: self.canOutOfBounding = not self.canOutOfBounding elif key == Qt.Key_B: self.showCenter = not self.showCenter self.update() #检查有向矩形框是否完整在图像区域内 def rotateOutOfBound(self, angle): if self.canOutOfBounding: return False for i, p in enumerate(self.selectedShape.points): if self.outOfPixmap(self.selectedShape.rotatePoint( p, angle)): #逐点判断是否在图像区域内 return True return False #移动一个像素位置 def moveOnePixel(self, direction): # print(self.selectedShape.points) if direction == 'Left' and not self.moveOutOfBound(QPointF(-1.0, 0)): # print("move Left one pixel") self.selectedShape.points[0] += QPointF(-1.0, 0) self.selectedShape.points[1] += QPointF(-1.0, 0) self.selectedShape.points[2] += QPointF(-1.0, 0) self.selectedShape.points[3] += QPointF(-1.0, 0) self.selectedShape.center += QPointF(-1.0, 0) elif direction == 'Right' and not self.moveOutOfBound(QPointF(1.0, 0)): # print("move Right one pixel") self.selectedShape.points[0] += QPointF(1.0, 0) self.selectedShape.points[1] += QPointF(1.0, 0) self.selectedShape.points[2] += QPointF(1.0, 0) self.selectedShape.points[3] += QPointF(1.0, 0) self.selectedShape.center += QPointF(1.0, 0) elif direction == 'Up' and not self.moveOutOfBound(QPointF(0, -1.0)): # print("move Up one pixel") self.selectedShape.points[0] += QPointF(0, -1.0) self.selectedShape.points[1] += QPointF(0, -1.0) self.selectedShape.points[2] += QPointF(0, -1.0) self.selectedShape.points[3] += QPointF(0, -1.0) self.selectedShape.center += QPointF(0, -1.0) elif direction == 'Down' and not self.moveOutOfBound(QPointF(0, 1.0)): # print("move Down one pixel") self.selectedShape.points[0] += QPointF(0, 1.0) self.selectedShape.points[1] += QPointF(0, 1.0) self.selectedShape.points[2] += QPointF(0, 1.0) self.selectedShape.points[3] += QPointF(0, 1.0) self.selectedShape.center += QPointF(0, 1.0) self.shapeMoved.emit() self.repaint() #判断在移动step(有方向)后,标注框是否完整在图像区域内 def moveOutOfBound(self, step): points = [ p1 + p2 for p1, p2 in zip(self.selectedShape.points, [step] * 4) ] return True in map(self.outOfPixmap, points) #设置最新标签 def setLastLabel(self, text): assert text self.shapes[-1].label = text return self.shapes[-1] def undoLastLine(self): assert self.shapes self.current = self.shapes.pop() self.current.setOpen() self.line.points = [self.current[-1], self.current[0]] self.drawingPolygon.emit(True) def resetAllLines(self): assert self.shapes self.current = self.shapes.pop() #移除最后一个标注框 self.current.setOpen() self.line.points = [self.current[-1], self.current[0]] self.drawingPolygon.emit(True) self.current = None self.drawingPolygon.emit(False) self.update() #加载图像 def loadPixmap(self, pixmap): self.pixmap = pixmap self.shapes = [] self.repaint() #加载所有标注框 def loadShapes(self, shapes): self.shapes = list(shapes) self.current = None self.repaint() #设置标注框可见 def setShapeVisible(self, shape, value): self.visible[shape] = value self.repaint() #将光标状态压入栈中 def overrideCursor(self, cursor): self.restoreCursor() self._cursor = cursor QApplication.setOverrideCursor(cursor) #把光标压到栈中 #将光标从栈中弹出 def restoreCursor(self): QApplication.restoreOverrideCursor() #把激活的光标从栈中弹出 #重置 def resetState(self): self.restoreCursor() self.pixmap = None self.update()
class Canvas(QtWidgets.QWidget): centerChanged = QtCore.Signal(QtCore.QPointF) zoomChanged = QtCore.Signal(float) frameChanged = QtCore.Signal(int) onMousePress = QtCore.Signal(QtCore.QPointF) drawingPolygon = QtCore.Signal(bool) newShape = QtCore.Signal(list) edgeSelected = QtCore.Signal(bool) selectionChanged = QtCore.Signal(list) scale = 1.0 mode = CREATE _createMode = 'polygon' _fill_drawing = False def __init__(self): super(Canvas, self).__init__() self.image_wapper = None self.epsilon = 10.0 self._curCursor = CURSOR_DEFAULT self._cursor = CURSOR_DEFAULT self.shapes = [] self.shapesBackups = [] self.selectedShapes = [] self.selectedShapesCopy = [] self.visible = {} self.current = None self.hShape = None self.hVertex = None self.hEdge = None self._hideBackround = False self.hideBackround = False self.movingShape = False self._fill_drawing = True self.isEnter = False self._Painter = QtGui.QPainter() self.lineColor = Shape.line_color self.line = Shape(line_color=self.lineColor, slice_type=None, slice_index=0) self.menu = QtWidgets.QMenu() self.setMouseTracking(True) self.setFocusPolicy(QtCore.Qt.WheelFocus) self._label = QtWidgets.QLabel("", self) self._label.setStyleSheet("color: #45804b") self._label.move(10, 10) self._tag_label = QtWidgets.QLabel("", self) self._tag_label.setStyleSheet("color: #FF0000") self._tag_label.move(10, 40) self._slider = FrameSlider(self) self._slider.setVisible(False) self._slider.valueChanged.connect(self.setSliceIndex) self._focus_delta = QtCore.QPoint(0, 0) def setMode(self, mode): Canvas.mode = mode self.unHighlight() self.deSelectShape() def resetImageToCenter(self): self._focus_delta = QtCore.QPoint(0, 0) def setCreateMode(self, value): if value not in Mode_ALL: raise ValueError('Unsupported createMode: %s' % value) Canvas._createMode = value def setFrameSliderEnabled(self, enabled): self._slider.setEnabled(enabled) def updateToCenter(self): idx = self.image_wapper.getCenterFrameIndex() self._slider.setValue(idx) def setImageWapper(self, wapper): self.image_wapper = wapper self._slider.setRange(0, self.image_wapper.maxFrame - 1) def drawing(self): return Canvas.mode == CREATE def editing(self): return Canvas.mode == EDIT def fillDrawing(self): return self._fill_drawing def setFillDrawing(self, value): self._fill_drawing = value def curFrameIndex(self): return self.image_wapper.sliceIndex def setSliceIndex(self, val): if self.image_wapper.update(val): self.update() self.frameChanged.emit(val) def wheelEvent(self, ev: QtGui.QWheelEvent): if not self.pixmap(): return mods = ev.modifiers() delta = ev.angleDelta() up = delta.y() > 0 if int(mods) == QtCore.Qt.ControlModifier: scale = self.scale * 1.1 if up else self.scale * 0.9 self.zoomChanged.emit(scale) else: curIdx = self.sliceIndex() v = curIdx - 1 if up else curIdx + 1 self._slider.setValue(v) @mouseMoveEventWapper def mouseMoveEvent(self, ev): """Update line with last point and current coordinates.""" if not self.pixmap(): return _pos = ev.localPos() pos = self.transformPos(_pos) vpos = utils.sliceToVoxPos(self, pos) app = QtWidgets.QApplication.instance() app.win.status("image{} vox{}".format(pos, vpos)) self.prevMovePoint = pos # self.restoreCursor() self._cursor = CURSOR_DEFAULT self._slider.setVisible(False) if (Qt.NoButton == ev.buttons() or Qt.LeftButton == ev.buttons())\ and not self.current and not self.movingShape: height = self.parent().height() width = self.parent().width() if height - _pos.y() < 50 and self.image_wapper.maxFrame > 1: self._slider.setVisible(True) self._slider.setFixedWidth(width - 60) self._slider.move(30, height - 25) self._slider.setRange(0, self.image_wapper.maxFrame - 1) self._slider.setValueNoSignal(self.sliceIndex()) self._cursor = CURSOR_SIZE return if QtCore.Qt.MidButton & ev.buttons(): mv = pos - self.prevPoint self._focus_delta += mv self.update() return # Polygon drawing. if self.drawing(): self.line.shape_type = self._createMode # self.overrideCursor(CURSOR_DRAW) self._cursor = CURSOR_DRAW if not self.current: return color = self.lineColor if self.outOfPixmap(pos): # Don't allow the user to draw outside the pixmap. # Project the point to the pixmap's edges. pos = self.intersectionPoint(self.current[-1], pos) elif len(self.current) > 1 and self._createMode == Mode_polygon and\ self.closeEnough(pos, self.current[0]): # Attract line to starting point and # colorise to alert the user. pos = self.current[0] color = self.current.line_color # self.overrideCursor(CURSOR_POINT) self._cursor = CURSOR_POINT self.current.highlightVertex(0, Shape.NEAR_VERTEX) self.line.highlightVertex(1, Shape.NEAR_VERTEX) if self._createMode in [Mode_polygon, Mode_linestrip]: self.line[0] = self.current[-1] self.line[1] = pos elif self._createMode in [Mode_box, Mode_rectangle]: self.line.points = [self.current[0], pos] self.line.close() elif self._createMode == Mode_circle: self.line.points = [self.current[0], pos] self.line.shape_type = Mode_circle elif self._createMode == Mode_line: self.line.points = [self.current[0], pos] self.line.close() elif self._createMode == Mode_point: self.line.points = [self.current[0]] self.line.close() self.line.line_color = color self.repaint() self.current.highlightClear() self.line.highlightClear() return # Polygon copy moving. if QtCore.Qt.RightButton & ev.buttons(): if self.selectedShapesCopy and self.prevPoint: # self.overrideCursor(CURSOR_MOVE) self._cursor = CURSOR_MOVE self.boundedMoveShapes(self.selectedShapesCopy, pos) self.repaint() elif self.selectedShapes: self.selectedShapesCopy = \ [s.copy() for s in self.selectedShapes] self.repaint() return # Polygon/Vertex moving. self.movingShape = False if QtCore.Qt.LeftButton & ev.buttons(): if self.selectedVertex(): self.boundedMoveVertex(pos) self.repaint() self.movingShape = True elif self.selectedShapes and self.prevPoint: # self.overrideCursor(CURSOR_MOVE) self._cursor = CURSOR_MOVE self.boundedMoveShapes(self.selectedShapes, pos) self.repaint() self.movingShape = True return # Just hovering over the canvas, 2 posibilities: # - Highlight shapes # - Highlight vertex # Update shape/vertex fill and tooltip value accordingly. # self.setToolTip("Image") for shape in reversed([s for s in self.shapes if self.isVisible(s)]): # Look for a nearby vertex to highlight. If that fails, # check if we happen to be inside a shape. index = shape.nearestVertex(pos, self.epsilon / self.scale) index_edge = shape.nearestEdge(pos, self.epsilon / self.scale) if index is not None: if self.selectedVertex(): self.hShape.highlightClear() self.hVertex = index self.hShape = shape self.hEdge = index_edge shape.highlightVertex(index, shape.MOVE_VERTEX) # self.overrideCursor(CURSOR_POINT) self._cursor = CURSOR_POINT self.setToolTip("Click & drag to move point") self.setStatusTip(self.toolTip()) self.update() break elif shape.containsPoint(pos): if self.selectedVertex(): self.hShape.highlightClear() self.hVertex = None self.hShape = shape self.hEdge = index_edge self.setToolTip("Click & drag to move shape '%s'" % shape.label.desc) self.setStatusTip(self.toolTip()) # self.overrideCursor(CURSOR_GRAB) self._cursor = CURSOR_GRAB self.update() break else: # Nothing found, clear highlights, reset state. if self.hShape: self.hShape.highlightClear() self.update() self.hVertex, self.hShape, self.hEdge = None, None, None self.edgeSelected.emit(self.hEdge is not None) def mousePressEvent(self, ev): if not self.pixmap(): return pos = self.transformPos(ev.localPos()) self._curPos = pos if ev.button() == QtCore.Qt.LeftButton: if self.drawing(): if self.current: # Add point to existing shape. if self._createMode == Mode_polygon: self.current.addPoint(self.line[1]) self.line[0] = self.current[-1] if self.current.isClosed(): self.finalise() elif self._createMode in [ Mode_rectangle, Mode_circle, Mode_line ]: assert len(self.current.points) == 1 self.current.points = self.line.points self.finalise() elif self._createMode in [Mode_box]: assert len(self.current.points) == 1 self.current.points = self.line.points self.newShape.emit([self.current]) self.setHiding(False) self.update() self.current = None elif self._createMode == Mode_linestrip: self.current.addPoint(self.line[1]) self.line[0] = self.current[-1] if int(ev.modifiers()) == QtCore.Qt.ControlModifier: self.finalise() elif not self.outOfPixmap(pos): # Create new shape. self.current = Shape(shape_type=self._createMode, slice_type=self.sliceType(), slice_index=self.sliceIndex()) self.current.addPoint(pos) self.line.line_color = self.current.line_color self.line.slice_type = self.sliceType() self.line.slice_index = self.sliceIndex() if self._createMode == Mode_point: self.finalise() else: if self._createMode == Mode_circle: self.current.shape_type = Mode_circle self.line.points = [pos, pos] self.setHiding() self.drawingPolygon.emit(True) self.update() else: group_mode = (int(ev.modifiers()) == QtCore.Qt.ControlModifier) self.selectShapePoint(pos, multiple_selection_mode=group_mode) self.prevPoint = pos self.repaint() elif ev.button() == QtCore.Qt.RightButton and self.editing(): group_mode = (int(ev.modifiers()) == QtCore.Qt.ControlModifier) self.selectShapePoint(pos, multiple_selection_mode=group_mode) self.prevPoint = pos self.repaint() elif ev.button() == QtCore.Qt.MidButton: self.prevPoint = pos def mouseReleaseEvent(self, ev): if ev.button() == QtCore.Qt.RightButton: if len(self.selectedShapesCopy) > 0 and len(self.selectedShapes) > 0\ and self.shapeDistanceMean(self.selectedShapes[0], self.selectedShapesCopy[0]) > 4: self.copySelectedShapes() self.repaint() else: self.menu.exec_(self.mapToGlobal(ev.pos())) self.selectedShapesCopy = [] elif ev.button() == QtCore.Qt.LeftButton and self.selectedShapes: self._cursor = CURSOR_GRAB if self.movingShape and self.hShape: index = self.shapes.index(self.hShape) if (self.shapesBackups[-1][index].points != self.shapes[index].points): self.storeShapes() # self.shapeMoved.emit() self.movingShape = False def keyPressEvent(self, ev): key = ev.key() if key == QtCore.Qt.Key_Escape and self.current: self.current = None self.drawingPolygon.emit(False) self.update() elif key == QtCore.Qt.Key_Return and self.canCloseShape(): self.finalise() elif key == QtCore.Qt.Key_Space: self.newTagLabel() def newTagLabel(self): self.current = Shape(shape_type=Mode_tag, slice_type=self.sliceType(), slice_index=self.sliceIndex()) self.finalise() def shapeDistanceMean(self, shape1, shape2): return utils.distance(shape1[0] - shape2[0]) def copySelectedShapes(self): if self.selectedShapesCopy: for s in self.selectedShapesCopy: self.shapes.append(s) self.newShape.emit(self.selectedShapesCopy) self.selectionChanged.emit(self.selectedShapesCopy) self.selectedShapesCopy = [] def calculateOffsets(self, shape, point): rect = shape.boundingRect() x1 = rect.x() - point.x() y1 = rect.y() - point.y() x2 = (rect.x() + rect.width() - 1) - point.x() y2 = (rect.y() + rect.height() - 1) - point.y() self.offsets = QtCore.QPoint(x1, y1), QtCore.QPoint(x2, y2) def boundedMoveVertex(self, pos): index, shape = self.hVertex, self.hShape point = shape[index] if self.outOfPixmap(pos): pos = self.intersectionPoint(point, pos) shape.moveVertexBy(index, pos - point) def boundedMoveShapes(self, shapes, pos): if self.outOfPixmap(pos): return False # No need to move o1 = pos + self.offsets[0] if self.outOfPixmap(o1): pos -= QtCore.QPoint(min(0, o1.x()), min(0, o1.y())) o2 = pos + self.offsets[1] if self.outOfPixmap(o2): pos += QtCore.QPoint(min(0, self.pixmap().width() - o2.x()), min(0, self.pixmap().height() - o2.y())) # XXX: The next line tracks the new position of the cursor # relative to the shape, but also results in making it # a bit "shaky" when nearing the border and allows it to # go outside of the shape's area for some reason. # self.calculateOffsets(self.selectedShapes, pos) dp = pos - self.prevPoint if dp: for shape in shapes: shape.moveBy(dp) self.prevPoint = pos return True return False def deleteSelected(self): del_shapes = [s for s in self.selectedShapes if s in self.shapes] self.shapes = [s for s in self.shapes if s not in self.selectedShapes] self.storeShapes() self.selectedShapes = [] self.update() return del_shapes def sliceType(self): return self.image_wapper.sliceType def sliceIndex(self): return self.image_wapper.sliceIndex def selectShapes(self, shapes): if not self.image_wapper: return t = [ s for s in shapes if s.slice_type == self.sliceType() and s in self.shapes ] if t: self.selectionChanged.emit(t) self.setHiding(True) self.update() def deSelectShape(self): if self.selectedShapes: self.setHiding(False) self.selectionChanged.emit([]) self.update() def selectShapePoint(self, point, multiple_selection_mode): """Select the first shape created which contains this point.""" if self.selectedVertex(): # A vertex is marked for selection. index, shape = self.hVertex, self.hShape shape.highlightVertex(index, shape.MOVE_VERTEX) else: for shape in reversed(self.shapes): if self.isVisible(shape) and shape.containsPoint(point): self.calculateOffsets(shape, point) self.setHiding() if multiple_selection_mode: if shape not in self.selectedShapes: self.selectionChanged.emit(self.selectedShapes + [shape]) else: self.selectionChanged.emit([shape]) return self.deSelectShape() def intersectionPoint(self, p1, p2): # Cycle through each image edge in clockwise fashion, # and find the one intersecting the current line segment. # http://paulbourke.net/geometry/lineline2d/ size = self.pixmap().size() points = [(0, 0), (size.width() - 1, 0), (size.width() - 1, size.height() - 1), (0, size.height() - 1)] # x1, y1 should be in the pixmap, x2, y2 should be out of the pixmap x1 = min(max(p1.x(), 0), size.width() - 1) y1 = min(max(p1.y(), 0), size.height() - 1) x2, y2 = p2.x(), p2.y() d, i, (x, y) = min(self.intersectingEdges((x1, y1), (x2, y2), points)) x3, y3 = points[i] x4, y4 = points[(i + 1) % 4] if (x, y) == (x1, y1): # Handle cases where previous point is on one of the edges. if x3 == x4: return QtCore.QPoint(x3, min(max(0, y2), max(y3, y4))) else: # y3 == y4 return QtCore.QPoint(min(max(0, x2), max(x3, x4)), y3) return QtCore.QPoint(x, y) def intersectingEdges(self, point1, point2, points): """Find intersecting edges. For each edge formed by `points', yield the intersection with the line segment `(x1,y1) - (x2,y2)`, if it exists. Also return the distance of `(x2,y2)' to the middle of the edge along with its index, so that the one closest can be chosen. """ (x1, y1) = point1 (x2, y2) = point2 for i in range(4): x3, y3 = points[i] x4, y4 = points[(i + 1) % 4] denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1) nua = (x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3) nub = (x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3) if denom == 0: # This covers two cases: # nua == nub == 0: Coincident # otherwise: Parallel continue ua, ub = nua / denom, nub / denom if 0 <= ua <= 1 and 0 <= ub <= 1: x = x1 + ua * (x2 - x1) y = y1 + ua * (y2 - y1) m = QtCore.QPoint((x3 + x4) / 2, (y3 + y4) / 2) d = utils.distance(m - QtCore.QPoint(x2, y2)) yield d, i, (x, y) def addPointToEdge(self): if (self.hShape is None and self.hEdge is None and self.prevMovePoint is None): return shape = self.hShape index = self.hEdge point = self.prevMovePoint shape.insertPoint(index, point) shape.highlightVertex(index, shape.MOVE_VERTEX) self.hShape = shape self.hVertex = index self.hEdge = None def finalise(self): assert self.current self.current.close() self.shapes.append(self.current) self.current = None self.setHiding(False) self.newShape.emit(self.shapes[-1:]) self.storeShapes() self.update() def undoLastPoint(self): if not self.current or self.current.isClosed(): # self.restoreShape() return self.current.popPoint() if len(self.current) > 0: self.line[0] = self.current[-1] else: self.current = None self.drawingPolygon.emit(False) self.repaint() def isShapeRestorable(self): if len(self.shapesBackups) < 2: return False return True def storeShapes(self): shapesBackup = [] for shape in self.shapes: shapesBackup.append(shape.copy()) if len(self.shapesBackups) >= 10: self.shapesBackups = self.shapesBackups[-9:] self.shapesBackups.append(shapesBackup) print(self.shapesBackups) def restoreShape(self): if not self.isShapeRestorable(): return self.shapesBackups.pop() # latest shapesBackup = self.shapesBackups.pop() self.shapes = shapesBackup self.selectedShapes = [] for shape in self.shapes: shape.selected = False self.repaint() print('restoreShape', shapesBackup) def loadShapes(self, shapes, replace=True): if replace: self.shapes = list(shapes) else: self.shapes.extend(shapes) self.storeShapes() self.current = None self.hShape = None self.hVertex = None self.hEdge = None self.repaint() def lastShape(self): if len(self.shapes) > 0: return self.shapes[-1] return None def setShapeVisible(self, shape, value): if shape and shape.slice_type == self.sliceType(): self.visible[shape] = value self.repaint() def setHiding(self, enable=True): self._hideBackround = self.hideBackround if enable else False def unHighlight(self): if self.hShape: self.hShape.highlightClear() self.hVertex = self.hShape = None def selectedVertex(self): return self.hVertex is not None def paintEvent(self, ev): # if not self.shapes: # return super(Canvas, self).paintEvent(ev) if not self.pixmap(): return tag_strs = [] p = self._Painter p.begin(self) p.setRenderHint(QPainter.Antialiasing) p.setRenderHint(QPainter.HighQualityAntialiasing) p.setRenderHint(QPainter.SmoothPixmapTransform) p.scale(self.scale, self.scale) p.translate(self.offsetToCenter()) # p.setPen(QColor(255, 0, 0)) p.drawImage(0, 0, self.pixmap()) Shape.scale = self.scale for shape in self.shapes: if (shape.selected or not self._hideBackround) and \ self.isVisible(shape): shape.fill = shape.selected or shape == self.hShape if shape.shape_type == Mode_tag: tag_strs.append(shape.label.desc) elif shape.shape_type == Mode_box: shape.paint(p) else: shape.paint(p) if self.current: self.current.paint(p) self.line.line_color = self.current.line_color self.line.paint(p) if self.selectedShapesCopy: for s in self.selectedShapesCopy: s.paint(p) if (self.fillDrawing() and self._createMode == Mode_polygon and self.current is not None and len(self.current.points) >= 2): drawing_shape = self.current.copy() drawing_shape.addPoint(self.line[1]) drawing_shape.fill = True drawing_shape.fill_color.setAlpha(64) drawing_shape.paint(p) p.end() self._label.setText(str(self.sliceIndex())) self._label.adjustSize() self._tag_label.setText(' | '.join(tag_strs)) self._tag_label.adjustSize() def pixmap(self): if self.image_wapper: return self.image_wapper.getQImage() return None def isVisible(self, shape): return self.visible.get(shape, True) def closeEnough(self, p1, p2): return utils.distance(p1 - p2) < (self.epsilon / self.scale) def outOfPixmap(self, p): w, h = self.pixmap().width(), self.pixmap().height() return not (0 <= p.x() <= w - 1 and 0 <= p.y() <= h - 1) def transformPos(self, point): return point / self.scale - self.offsetToCenter() def offsetToCenter(self): pixmap = self.pixmap() s = self.scale area = super(Canvas, self).size() w, h = pixmap.width() * s, pixmap.height() * s aw, ah = area.width(), area.height() x = (aw - w) / (2 * s) if aw > w else 0 y = (ah - h) / (2 * s) if ah > h else 0 return QtCore.QPoint(x, y) + self._focus_delta def sizeHint(self): return self.minimumSizeHint() def minimumSizeHint(self): if self.pixmap(): return self.scale * self.pixmap().size() return super(Canvas, self).minimumSizeHint() def leaveEvent(self, ev): self.restoreCursor() self._slider.setVisible(False) self.isEnter = False def focusOutEvent(self, ev): self.restoreCursor() def enterEvent(self, ev): self.overrideCursor(self._curCursor) self.isEnter = True def overrideCursor(self, cursor): old_cursor = QApplication.overrideCursor() if old_cursor == None or old_cursor != cursor: QApplication.restoreOverrideCursor() QApplication.setOverrideCursor(cursor) self._curCursor = cursor @staticmethod def restoreCursor(): QApplication.restoreOverrideCursor()
class Canvas(QWidget): zoomRequest = pyqtSignal(int) scrollRequest = pyqtSignal(int, int) newShape = pyqtSignal() selectionChanged = pyqtSignal(bool) shapeMoved = pyqtSignal() drawingPolygon = pyqtSignal(bool) CREATE, EDIT = list(range(2)) epsilon = 11.0 def __init__(self, lineColors, *args, **kwargs): super(Canvas, self).__init__(*args, **kwargs) # get the labelHist to make different color self.lineColors = lineColors # Initialise local state. self.mode = self.EDIT self.shapes = [] self.current = None self.selectedShape = None # save the selected shape here self.selectedShapeCopy = None self.lineColor = QColor(0, 0, 255) self.line = Shape() self.prevPoint = QPointF() self.offsets = QPointF(), QPointF() self.scale = 1.0 self.pixmap = QPixmap() self.visible = {} self._hideBackround = False self.hideBackround = False self.hShape = None self.hVertex = None self._painter = QPainter() self._cursor = CURSOR_DEFAULT self.CURSOR_ROTATION_W = QCursor(QPixmap('icons/rotation_w.png'), -1, -1) self.CURSOR_ROTATION = QCursor(QPixmap('icons/rotation_w.png'), -1, -1) # Menus: self.menus = (QMenu(), QMenu()) # Set widget options. self.setMouseTracking(True) self.setFocusPolicy(Qt.WheelFocus) self.verified = False # create for judging single click or double click self.pQTimerSingleClicked = QTimer(self) self._pos = QPointF(0, 0) self.pQTimerSingleClicked.timeout.connect(self.singleClickEvent) # create for press-move-release or press-release-move-press-release-... self.releaseTime = 0 self.isLeftPressed = False def enterEvent(self, ev): self.overrideCursor(self._cursor) def leaveEvent(self, ev): self.restoreCursor() def focusOutEvent(self, ev): self.restoreCursor() def isVisible(self, shape): return self.visible.get(shape, True) def drawing(self): return self.mode == self.CREATE def editing(self): return self.mode == self.EDIT def setEditing(self, value=True): self.mode = self.EDIT if value else self.CREATE if not value: # Create self.unHighlight() self.deSelectShape() self.prevPoint = QPointF() self.repaint() def unHighlight(self): if self.hShape: self.hShape.highlightClear() self.hVertex = self.hShape = None def selectedVertex(self): return self.hVertex is not None def mouseMoveEvent(self, ev): """Update line with last point and current coordinates.""" pos = self.transformPos(ev.pos()) # self.restoreCursor() # Polygon drawing. if self.drawing(): self.overrideCursor(CURSOR_DRAW) if self.current: color = self.lineColor if self.outOfPixmap(pos): # Don't allow the user to draw outside the pixmap. # Project the point to the pixmap's edges. pos = self.intersectionPoint(self.current[-1], pos) elif len(self.current) > 1 and self.closeEnough(pos, self.current[0]): # Attract line to starting point and colorise to alert the # user: pos = self.current[0] color = self.current.line_color self.overrideCursor(CURSOR_POINT) self.current.highlightVertex(0, Shape.NEAR_VERTEX) self.line[1] = pos self.line.line_color = color self.prevPoint = QPointF() self.current.highlightClear() else: self.prevPoint = pos self.repaint() else: # change the mouse style if it enters the selected shape rotation area. ## get the selected shape's points if self.selectedShape is not None and self.selectedShape._shapetype != 'Point': points = self.selectedShape.points p1 = (points[0]+points[1])/2 p2 = QPoint(p1.x(), p1.y()-15) # rotation button area topLeft = QPoint(p2.x()-20, p2.y()-40) bottomRight = QPoint(p2.x()+20, p2.y()) rect = QRect(topLeft, bottomRight) # change the mouse shape print(pos.x(), pos.y()) if pos.x() >= topLeft.x() and pos.x() <= bottomRight.x(): if pos.y() >= topLeft.y() and pos.y() <= bottomRight.y(): #self.overrideCursor(CURSOR_DRAW) print('here we should do something.') # Polygon copy moving. if Qt.RightButton & ev.buttons(): if self.selectedShapeCopy and self.prevPoint: self.overrideCursor(CURSOR_MOVE) self.boundedMoveShape(self.selectedShapeCopy, pos) self.repaint() elif self.selectedShape: self.selectedShapeCopy = self.selectedShape.copy() self.repaint() return # Polygon/Vertex moving. if Qt.LeftButton & ev.buttons(): if self.selectedVertex(): self.boundedMoveVertex(pos) self.shapeMoved.emit() self.repaint() elif self.selectedShape and self.prevPoint: self.overrideCursor(CURSOR_MOVE) self.boundedMoveShape(self.selectedShape, pos) self.shapeMoved.emit() self.repaint() return # Just hovering over the canvas, 2 possibilities: # - Highlight shapes # - Highlight vertex # Update shape/vertex fill and tooltip value accordingly. self.setToolTip("Image") for shape in reversed([s for s in self.shapes if self.isVisible(s)]): # Look for a nearby vertex to highlight. If that fails, # check if we happen to be inside a shape. index = shape.nearestVertex(pos, self.epsilon) if index is not None: if self.selectedVertex(): self.hShape.highlightClear() self.hVertex, self.hShape = index, shape shape.highlightVertex(index, shape.MOVE_VERTEX) self.overrideCursor(CURSOR_POINT) self.setToolTip("Click & drag to move point") self.setStatusTip(self.toolTip()) self.update() break elif shape.containsPoint(pos): if self.selectedVertex(): self.hShape.highlightClear() self.hVertex, self.hShape = None, shape self.setToolTip( "Click & drag to move shape '%s'" % shape.label) self.setStatusTip(self.toolTip()) self.overrideCursor(CURSOR_GRAB) self.update() break else: # Nothing found, clear highlights, reset state. if self.hShape: self.hShape.highlightClear() self.update() self.hVertex, self.hShape = None, None self.overrideCursor(CURSOR_DEFAULT) def mousePressEvent(self, ev): pos = self.transformPos(ev.pos()) self._pos = pos if ev.button() == Qt.LeftButton: if self.drawing(): self.isLeftPressed = True self.releaseTime = time.time() print('press: ', self.releaseTime) self.pQTimerSingleClicked.start(300) else: self.selectShapePoint(pos) self.prevPoint = pos self.repaint() elif ev.button() == Qt.RightButton and self.editing(): self.selectShapePoint(pos) self.prevPoint = pos self.repaint() def singleClickEvent(self): self.pQTimerSingleClicked.stop() pos = self._pos if self.drawing(): if not self.current: if not self.outOfPixmap(pos): self.current = Shape() self.current.addPoint(pos) self.line.points = [pos, pos] self.setHiding() self.drawingPolygon.emit(True) self.update() else: if not self.current.reachMaxPoints(): self.current.addPoint(pos) self.line.points = [pos, pos] if self.current.reachMaxPoints(): self.current._shapetype = 'Polygon' self.finalise() else: self.selectShapePoint(pos) self.prevPoint = pos self.repaint() def mouseDoubleClickEvent(self, ev): # We need at least 4 points here, since the mousePress handler # adds an extra one before this handler is called. self.pQTimerSingleClicked.stop() print('Double Click') if self.current: print('None') else: pos = self.transformPos(ev.pos()) self.handleDrawingPoint(pos) #if self.canCloseShape() and len(self.current) > 3: #self.current.popPoint() #self.finalise() def handleDrawingPoint(self, pos): print('Enter handleDrawingPoint') if not self.outOfPixmap(pos): self.current = Shape() self.current.addPoint(pos) self.line.points = [pos, pos] self.setHiding() self.drawingPolygon.emit(True) self.update() initPos = self.current[0] minX = initPos.x() minY = initPos.y() targetPos = self.line[1] maxX = targetPos.x() maxY = targetPos.y() self.current.addPoint(QPointF(maxX, minY)) self.current.addPoint(targetPos) self.current.addPoint(QPointF(minX, maxY)) self.current._shapetype = 'Point' self.finalise() def handleDrawingRect(self, pos): if self.current and self.current.reachMaxPoints() is False: initPos = self.current[0] minX = initPos.x() minY = initPos.y() targetPos = self.line[1] maxX = targetPos.x() maxY = targetPos.y() self.current.addPoint(QPointF(maxX, minY)) self.current.addPoint(targetPos) self.current.addPoint(QPointF(minX, maxY)) if initPos.x() == targetPos.x() and initPos.y() == targetPos.y(): self.current._shapetype = 'Point' else: self.current._shapetype = 'Rect' self.finalise() def mouseReleaseEvent(self, ev): if ev.button() == Qt.RightButton: menu = self.menus[bool(self.selectedShapeCopy)] self.restoreCursor() if not menu.exec_(self.mapToGlobal(ev.pos()))\ and self.selectedShapeCopy: # Cancel the move by deleting the shadow copy. self.selectedShapeCopy = None self.repaint() elif ev.button() == Qt.LeftButton and self.selectedShape: if self.selectedVertex(): self.overrideCursor(CURSOR_POINT) else: self.overrideCursor(CURSOR_GRAB) elif ev.button() == Qt.LeftButton: print('release: ', time.time()) print((time.time() - self.releaseTime)*1000) self.isLeftPressed = False if (time.time() - self.releaseTime)*1000 > 200: # indicate press-move-release pos = self.transformPos(ev.pos()) if self.drawing(): self.handleDrawingRect(pos) def endMove(self, copy=False): assert self.selectedShape and self.selectedShapeCopy shape = self.selectedShapeCopy #del shape.fill_color #del shape.line_color if copy: self.shapes.append(shape) self.selectedShape.selected = False self.selectedShape = shape self.repaint() else: self.selectedShape.points = [p for p in shape.points] self.selectedShapeCopy = None def hideBackroundShapes(self, value): self.hideBackround = value if self.selectedShape: # Only hide other shapes if there is a current selection. # Otherwise the user will not be able to select a shape. self.setHiding(True) self.repaint() def setHiding(self, enable=True): self._hideBackround = self.hideBackround if enable else False def canCloseShape(self): return self.drawing() and self.current and len(self.current) > 2 def selectShape(self, shape): self.deSelectShape() shape.selected = True self.selectedShape = shape self.setHiding() self.selectionChanged.emit(True) self.update() def selectShapePoint(self, point): """Select the first shape created which contains this point.""" self.deSelectShape() if self.selectedVertex(): # A vertex is marked for selection. if self.hShape: spoints = self.hShape.points for spoint in spoints: for oshape in reversed(self.shapes): if oshape is not self.hShape and oshape.containsPoint(spoint): self.setShapeVisible(oshape, False) index, shape = self.hVertex, self.hShape shape.highlightVertex(index, shape.MOVE_VERTEX) self.selectShape(shape) return for shape in reversed(self.shapes): self.setShapeVisible(shape, True) if self.isVisible(shape) and shape.containsPoint(point): self.selectShape(shape) # hide the shapes which conver the points of the selected shape spoints = shape.points for spoint in spoints: for oshape in reversed(self.shapes): if oshape is not shape and oshape.containsPoint(spoint): self.setShapeVisible(oshape, False) self.calculateOffsets(shape, point) break def calculateOffsets(self, shape, point): rect = shape.boundingRect() x1 = rect.x() - point.x() y1 = rect.y() - point.y() x2 = (rect.x() + rect.width()) - point.x() y2 = (rect.y() + rect.height()) - point.y() self.offsets = QPointF(x1, y1), QPointF(x2, y2) def boundedMoveVertex(self, pos): index, shape = self.hVertex, self.hShape print(index, shape) point = shape[index] if self.outOfPixmap(pos): pos = self.intersectionPoint(point, pos) shiftPos = pos - point shape.moveVertexBy(index, shiftPos) #if shape[0] == shape[-1]: #indicate the shape is a point #return if shape._shapetype == 'Point': return elif shape._shapetype == 'Rect': lindex = (index + 1) % 4 rindex = (index + 3) % 4 lshift = None rshift = None if index % 2 == 0: rshift = QPointF(shiftPos.x(), 0) lshift = QPointF(0, shiftPos.y()) else: lshift = QPointF(shiftPos.x(), 0) rshift = QPointF(0, shiftPos.y()) shape.moveVertexBy(rindex, rshift) shape.moveVertexBy(lindex, lshift) def boundedMoveShape(self, shape, pos): if self.outOfPixmap(pos): return False # No need to move o1 = pos + self.offsets[0] if self.outOfPixmap(o1): pos -= QPointF(min(0, o1.x()), min(0, o1.y())) o2 = pos + self.offsets[1] if self.outOfPixmap(o2): pos += QPointF(min(0, self.pixmap.width() - o2.x()), min(0, self.pixmap.height() - o2.y())) # The next line tracks the new position of the cursor # relative to the shape, but also results in making it # a bit "shaky" when nearing the border and allows it to # go outside of the shape's area for some reason. XXX #self.calculateOffsets(self.selectedShape, pos) dp = pos - self.prevPoint if dp: shape.moveBy(dp) #self.constrainShape(shape) self.prevPoint = pos return True return False def deSelectShape(self): if self.selectedShape: self.selectedShape.selected = False self.selectedShape = None self.setHiding(False) self.selectionChanged.emit(False) self.update() def deleteSelected(self): if self.selectedShape: shape = self.selectedShape self.shapes.remove(self.selectedShape) self.selectedShape = None self.update() return shape def copySelectedShape(self): if self.selectedShape: shape = self.selectedShape.copy() self.deSelectShape() self.shapes.append(shape) shape.selected = True self.selectedShape = shape self.boundedShiftShape(shape) return shape def boundedShiftShape(self, shape): # Try to move in one direction, and if it fails in another. # Give up if both fail. point = shape[0] offset = QPointF(2.0, 2.0) self.calculateOffsets(shape, point) self.prevPoint = point if not self.boundedMoveShape(shape, point - offset): self.boundedMoveShape(shape, point + offset) def paintEvent(self, event): if not self.pixmap: return super(Canvas, self).paintEvent(event) p = self._painter p.begin(self) p.setRenderHint(QPainter.Antialiasing) p.setRenderHint(QPainter.HighQualityAntialiasing) p.setRenderHint(QPainter.SmoothPixmapTransform) p.scale(self.scale, self.scale) p.translate(self.offsetToCenter()) p.drawPixmap(0, 0, self.pixmap) Shape.scale = self.scale for shape in self.shapes: if (shape.selected or not self._hideBackround) and self.isVisible(shape): shape.fill = shape.selected or shape == self.hShape shape.paint(p) if self.current: self.current.paint(p) self.line.paint(p) if self.selectedShapeCopy: self.selectedShapeCopy.paint(p) # Paint rect when print(self.isLeftPressed) if self.current is not None and len(self.line) == 2 and self.isLeftPressed: leftTop = self.line[0] rightBottom = self.line[1] rectWidth = rightBottom.x() - leftTop.x() rectHeight = rightBottom.y() - leftTop.y() color = QColor(0, 220, 0) p.setPen(color) brush = QBrush(Qt.BDiagPattern) p.setBrush(brush) p.drawRect(leftTop.x(), leftTop.y(), rectWidth, rectHeight) if self.drawing() and not self.prevPoint.isNull() and not self.outOfPixmap(self.prevPoint): p.setPen(QColor(0, 0, 0)) p.drawLine(self.prevPoint.x(), 0, self.prevPoint.x(), self.pixmap.height()) p.drawLine(0, self.prevPoint.y(), self.pixmap.width(), self.prevPoint.y()) self.setAutoFillBackground(True) if self.verified: pal = self.palette() pal.setColor(self.backgroundRole(), QColor(184, 239, 38, 128)) self.setPalette(pal) else: pal = self.palette() pal.setColor(self.backgroundRole(), QColor(232, 232, 232, 255)) self.setPalette(pal) p.end() def transformPos(self, point): """Convert from widget-logical coordinates to painter-logical coordinates.""" return point / self.scale - self.offsetToCenter() def offsetToCenter(self): s = self.scale area = super(Canvas, self).size() w, h = self.pixmap.width() * s, self.pixmap.height() * s aw, ah = area.width(), area.height() x = (aw - w) / (2 * s) if aw > w else 0 y = (ah - h) / (2 * s) if ah > h else 0 return QPointF(x, y) def outOfPixmap(self, p): w, h = self.pixmap.width(), self.pixmap.height() return not (0 <= p.x() <= w and 0 <= p.y() <= h) def finalise(self): assert self.current #if self.current.points[0] == self.current.points[-1]: #self.current = None #self.drawingPolygon.emit(False) #self.update() #return self.current.close() # add for boundary #self.constrainShape(self.current) self.shapes.append(self.current) self.current = None self.setHiding(False) self.newShape.emit() self.update() def closeEnough(self, p1, p2): #d = distance(p1 - p2) #m = (p1-p2).manhattanLength() # print "d %.2f, m %d, %.2f" % (d, m, d - m) return distance(p1 - p2) < self.epsilon def intersectionPoint(self, p1, p2): # Cycle through each image edge in clockwise fashion, # and find the one intersecting the current line segment. # http://paulbourke.net/geometry/lineline2d/ size = self.pixmap.size() points = [(0, 0), (size.width(), 0), (size.width(), size.height()), (0, size.height())] x1, y1 = p1.x(), p1.y() x2, y2 = p2.x(), p2.y() d, i, (x, y) = min(self.intersectingEdges((x1, y1), (x2, y2), points)) x3, y3 = points[i] x4, y4 = points[(i + 1) % 4] if (x, y) == (x1, y1): # Handle cases where previous point is on one of the edges. if x3 == x4: return QPointF(x3, min(max(0, y2), max(y3, y4))) else: # y3 == y4 return QPointF(min(max(0, x2), max(x3, x4)), y3) return QPointF(x, y) def intersectingEdges(self, x1y1, x2y2, points): """For each edge formed by `points', yield the intersection with the line segment `(x1,y1) - (x2,y2)`, if it exists. Also return the distance of `(x2,y2)' to the middle of the edge along with its index, so that the one closest can be chosen.""" x1, y1 = x1y1 x2, y2 = x2y2 for i in range(4): x3, y3 = points[i] x4, y4 = points[(i + 1) % 4] denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1) nua = (x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3) nub = (x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3) if denom == 0: # This covers two cases: # nua == nub == 0: Coincident # otherwise: Parallel continue ua, ub = nua / denom, nub / denom if 0 <= ua <= 1 and 0 <= ub <= 1: x = x1 + ua * (x2 - x1) y = y1 + ua * (y2 - y1) m = QPointF((x3 + x4) / 2, (y3 + y4) / 2) d = distance(m - QPointF(x2, y2)) yield d, i, (x, y) # These two, along with a call to adjustSize are required for the # scroll area. def sizeHint(self): return self.minimumSizeHint() def minimumSizeHint(self): if self.pixmap: return self.scale * self.pixmap.size() return super(Canvas, self).minimumSizeHint() def wheelEvent(self, ev): qt_version = 4 if hasattr(ev, "delta") else 5 if qt_version == 4: if ev.orientation() == Qt.Vertical: v_delta = ev.delta() h_delta = 0 else: h_delta = ev.delta() v_delta = 0 else: delta = ev.angleDelta() h_delta = delta.x() v_delta = delta.y() mods = ev.modifiers() if Qt.ControlModifier == int(mods) and v_delta: self.zoomRequest.emit(v_delta) else: v_delta and self.scrollRequest.emit(v_delta, Qt.Vertical) h_delta and self.scrollRequest.emit(h_delta, Qt.Horizontal) ev.accept() def keyPressEvent(self, ev): key = ev.key() if key == Qt.Key_Escape and self.current: print('ESC press') self.current = None self.drawingPolygon.emit(False) self.update() elif key == Qt.Key_Return and self.canCloseShape(): self.finalise() elif key == Qt.Key_Left and self.selectedShape: self.moveOnePixel('Left') elif key == Qt.Key_Right and self.selectedShape: self.moveOnePixel('Right') elif key == Qt.Key_Up and self.selectedShape: self.moveOnePixel('Up') elif key == Qt.Key_Down and self.selectedShape: self.moveOnePixel('Down') def moveOnePixel(self, direction): # print(self.selectedShape.points) if direction == 'Left' and not self.moveOutOfBound(QPointF(-1.0, 0)): # print("move Left one pixel") self.selectedShape.points[0] += QPointF(-1.0, 0) self.selectedShape.points[1] += QPointF(-1.0, 0) self.selectedShape.points[2] += QPointF(-1.0, 0) self.selectedShape.points[3] += QPointF(-1.0, 0) elif direction == 'Right' and not self.moveOutOfBound(QPointF(1.0, 0)): # print("move Right one pixel") self.selectedShape.points[0] += QPointF(1.0, 0) self.selectedShape.points[1] += QPointF(1.0, 0) self.selectedShape.points[2] += QPointF(1.0, 0) self.selectedShape.points[3] += QPointF(1.0, 0) elif direction == 'Up' and not self.moveOutOfBound(QPointF(0, -1.0)): # print("move Up one pixel") self.selectedShape.points[0] += QPointF(0, -1.0) self.selectedShape.points[1] += QPointF(0, -1.0) self.selectedShape.points[2] += QPointF(0, -1.0) self.selectedShape.points[3] += QPointF(0, -1.0) elif direction == 'Down' and not self.moveOutOfBound(QPointF(0, 1.0)): # print("move Down one pixel") self.selectedShape.points[0] += QPointF(0, 1.0) self.selectedShape.points[1] += QPointF(0, 1.0) self.selectedShape.points[2] += QPointF(0, 1.0) self.selectedShape.points[3] += QPointF(0, 1.0) self.shapeMoved.emit() self.repaint() def moveOutOfBound(self, step): points = [p1+p2 for p1, p2 in zip(self.selectedShape.points, [step]*4)] return True in map(self.outOfPixmap, points) def setLastLabel(self, text): assert text self.shapes[-1].label = text def setLastColor(self, color): assert color self.shapes[-1].line_color = color def undoLastLine(self): assert self.shapes self.current = self.shapes.pop() self.current.setOpen() self.line.points = [self.current[-1], self.current[0]] self.drawingPolygon.emit(True) def resetAllLines(self): assert self.shapes self.current = self.shapes.pop() self.current.setOpen() self.line.points = [self.current[-1], self.current[0]] self.drawingPolygon.emit(True) self.current = None self.drawingPolygon.emit(False) self.update() def loadPixmap(self, pixmap): self.pixmap = pixmap self.shapes = [] self.repaint() def loadShapes(self, shapes): self.shapes = list(shapes) self.current = None self.repaint() def setShapeVisible(self, shape, value): self.visible[shape] = value self.repaint() def currentCursor(self): cursor = QApplication.overrideCursor() if cursor is not None: cursor = cursor.shape() return cursor def overrideCursor(self, cursor): self._cursor = cursor if self.currentCursor() is None: QApplication.setOverrideCursor(cursor) else: QApplication.changeOverrideCursor(cursor) def restoreCursor(self): QApplication.restoreOverrideCursor() def resetState(self): self.restoreCursor() self.pixmap = None self.update()
class Canvas(QWidget): selectionChanged = pyqtSignal(bool) shapeMoved = pyqtSignal() newShape = pyqtSignal() CREATE, EDIT = list(range(2)) # CREATE, EDIT = [0, 1] epsilon = 11.0 def __init__(self, parent=None): super(Canvas, self).__init__(parent) self.painter = QPainter() self.pixmap = QPixmap() # self = MyLabel(self) self.mode = self.EDIT # 默认模式为编辑 self.cursor = CURSOR_DEFAULT # 默认为鼠标手型 # 初始点,move位置,结束点 self.start_point = None self.pos = None self.end_point = None self.drawingLineColor = QColor(0, 0, 255) # # 用于保存正在画的rect的2个顶点 # 长度最大为2 # 实时变化(指[1]位置) # 画下一个时会进行re操作 self.current = [] self.points = Shape(line_color=self.drawingLineColor) self.point_four = Shape(line_color=self.drawingLineColor) # 存储画好的rect # 例如:存储了两个已经画好的rect: # print(self.shapes) # [<AAA_labelImg_copy.shape.Shape object at 0x000002403CA45508>, # <AAA_labelImg_copy.shape.Shape object at 0x0000024043553FC8>] self.shapes = [] self.shapes_remove = [] # 未实现的功能 # self.ten_bottle = QPointF() # 高亮的顶点 self.hVertex = None self.visible = {} self.hShape = None self.selectedShape = None # 存放选择的rect self.scale = 1.0 # 用于同一刻度 self.offsets = QPointF(), QPointF() # 2019-11-19 # for move shape self.prevPoint = QPointF() # 2019-11-20 # for move shape self._hideBackround = False self.hideBackround = False self.setMouseTracking(True) def currentCursor(self): """输出当前手型""" cursor = QApplication.overrideCursor() if cursor is not None: cursor = cursor.shape() # 默认输出的是0, # print('cursor in current: ', cursor) # eg_print:18,17 具体可以参考最上面的默认 return cursor def overrideCursor(self, cursor): # 使用input的cursor覆盖掉当前的手型 self.cursor = cursor if self.currentCursor() is None: QApplication.setOverrideCursor(cursor) # print('cursor in 1: ', cursor) else: QApplication.changeOverrideCursor(cursor) # print('cursor in 2: ', cursor) def mode_to_create(self): self.mode = self.CREATE return self.mode def mode_to_edit(self): self.mode = self.EDIT return self.mode def creating(self): return self.mode == self.CREATE def editing(self): return self.mode == self.EDIT def out_of_pixmap(self, p): width, height = self.pixmap.width(), self.pixmap.height() # print(width, height) if 0 <= p.x() <= width and 0 <= p.y() <= height: return False return True def un_highlight(self): self.hVertex = None def selected_vertex(self): # 选中的顶点会高亮,这里判断hVertex即可 return self.hVertex is not None def bounded_move_vertex(self, pos): """通过一个鼠标实时位置pos来移动这个选中的顶点,当然他是高亮的""" index, shape = self.hVertex, self.hShape # # print('index, shape:', index, shape) point = shape[index] # print('point:', point) # if self.out_of_pixmap(pos): # pos = self.intersectionPoint(point, pos) shiftPos = pos - point shape.moveVertexBy(index, shiftPos) lindex = (index + 1) % 4 rindex = (index + 3) % 4 lshift = None rshift = None if index % 2 == 0: # index: 2, 4, 6, 8 rshift = QPointF(shiftPos.x(), 0) lshift = QPointF(0, shiftPos.y()) else: lshift = QPointF(shiftPos.x(), 0) rshift = QPointF(0, shiftPos.y()) shape.moveVertexBy(rindex, rshift) shape.moveVertexBy(lindex, lshift) def isVisible(self, shape): # visable:可见 # 是否可见 return self.visible.get(shape, True) def selectedVertex(self): return self.hVertex is not None def setLastLabel(self, text): # 从这里添加label assert text # 断言 self.shapes[-1].label = text return self.shapes[-1] def mouseMoveEvent(self, event): # 通过第一个点和当前坐标更新线条 # vertex和shape的移动也在这个方法中实现 try: pos = self.transformPos(event.pos()) # print(pos) # # 在状态栏实时的显示鼠标的位置 # window = self.parent().window() # if window is not None: # self.parent().window().labelCoordinates.setText( # 'X: %d; Y: %d' % (pos.x(), pos.y())) # 创建rect # 实现逻辑:将实时变化的第二个点存储在self.points中 # 配合上paintEvent方法,就可以做到实时展示 if self.creating(): self.overrideCursor(CURSOR_DRAW) # 出现十字手型 if not self.out_of_pixmap(pos): if event.buttons() & Qt.LeftButton: # 左击拖拽更新rect的第二个点 # 永远保持len(self.points) <= 2 if len(self.points) == 1: self.points.addPoint(pos) elif len(self.points) == 2: self.points.popPoint() self.points.addPoint(pos) self.repaint() self.prevPoint = QPointF() elif self.out_of_pixmap(pos): # 这里需要增加一个points self.overrideCursor(CURSOR_DEFAULT) # 如果在图片外,回到默认手形 # print('self.points[-1]', self.points[-1]) else: self.prevPoint = pos # 移动顶点 vertex # 移动rect shape if not self.out_of_pixmap(pos): if Qt.LeftButton & event.buttons(): # 实现左击+拖拽 if self.selected_vertex(): # if self.hVertex is not None: self.bounded_move_vertex(pos) self.shapeMoved.emit() self.repaint() # elif self.selectedShape and self.prevPoint: # print('self.selectedShape:', self.selectedShape) # self.overrideCursor(CURSOR_MOVE) # self.boundedMoveShape(self.selectedShape, pos) # # self.shapeMoved.emit() # self.repaint() return self.setToolTip("图片") # 在画布外围,工具提示值为Image # 顶点和图形高亮 for shape in reversed([ s for s in self.shapes if self.isVisible(s) ]): # reversed()之后只在第一次遍历时返回值 # 寻找一个附近的顶点以突出高亮; # 如果失败了,检查我们是否在形状的里面 index = shape.nearestVertex( pos, self.epsilon) # self.epsilon == 11.0 if index is not None: # 存在最近点 if self.selectedVertex( ): # if self.hVertex is not None 移动顶点 self.hShape.highlightClear() # 清除高亮 self.hVertex, self.hShape = index, shape shape.highlightVertex( index, shape.MOVE_VERTEX) # 顶点高亮, nidex:顶点的序列号 self.overrideCursor(CURSOR_POINT) # 鼠标手型变为CURSOR_POINT self.setToolTip("单击并拖动可以移动顶点(* ̄︶ ̄)") # 展示提示信息 self.setStatusTip(self.toolTip()) self.update() break # elif shape.containsPoint(pos): # if self.selectedVertex(): # self.hShape.highlightClear() # self.hVertex, self.hShape = None, shape # self.setToolTip( # "单击并拖动可以移动这个矩形框 '%s'" % shape.label) # self.setStatusTip(self.toolTip()) # self.overrideCursor(CURSOR_GRAB) # self.update() # break else: # Nothing found, clear highlights, reset state. if self.hShape: self.hShape.highlightClear() self.update() self.hVertex, self.hShape = None, None if self.currentCursor() == 2: pass else: self.overrideCursor(CURSOR_DEFAULT) except Exception as e: print('Error in mouseMoveEvent:', e) def mousePressEvent(self, event): # 在打开本软件时,不能直接进入mouseMove功能,这个点还是需要修改的。 try: pos = event.pos() self.start_point = self.transformPos(pos) if event.button() == Qt.LeftButton: if not self.out_of_pixmap(self.start_point): if self.creating(): self.points.addPoint(self.start_point) else: self.selectShapePoint(pos) self.prevPoint = pos self.repaint() else: QMessageBox.warning(self, 'WARNING', '不可以在图片外创建!!!', QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) except Exception as e: print('Error in mousePressEvent:', e) def deSelectShape(self): if self.selectedShape: self.selectedShape.selected = False self.selectedShape = None self.setHiding(False) self.selectionChanged.emit(False) self.update() def selectShapePoint(self, point): """Select the first shape created which contains this point.""" self.deSelectShape() if self.selectedVertex(): # A vertex is marked for selection. index, shape = self.hVertex, self.hShape shape.highlightVertex(index, shape.MOVE_VERTEX) self.selectShape(shape) return for shape in reversed(self.shapes): if self.isVisible(shape) and shape.containsPoint(point): self.selectShape(shape) self.calculateOffsets(shape, point) return def calculateOffsets(self, shape, point): rect = shape.boundingRect() x1 = rect.x() - point.x() y1 = rect.y() - point.y() x2 = (rect.x() + rect.width()) - point.x() y2 = (rect.y() + rect.height()) - point.y() self.offsets = QPointF(x1, y1), QPointF(x2, y2) def selectShape(self, shape): self.deSelectShape() # re shape.selected = True self.selectedShape = shape self.setHiding() self.selectionChanged.emit(True) self.update() def setHiding(self, enable=True): self._hideBackround = self.hideBackround if enable else False def boundedMoveShape(self, shape, pos): # ??? if self.out_of_pixmap(pos): return False # No need to move o1 = pos + self.offsets[0] if self.out_of_pixmap(o1): pos -= QPointF(min(0, o1.x()), min(0, o1.y())) o2 = pos + self.offsets[1] if self.out_of_pixmap(o2): pos += QPointF(min(0, self.pixmap.width() - o2.x()), min(0, self.pixmap.height() - o2.y())) # The next line tracks the new position of the cursor # relative to the shape, but also results in making it # a bit "shaky" when nearing the border and allows it to # go outside of the shape's area for some reason. XXX # self.calculateOffsets(self.selectedShape, pos) dp = pos - self.prevPoint if dp: shape.moveBy(dp) self.prevPoint = pos return True return False def mouseReleaseEvent(self, event): try: self.end_point = self.transformPos(event.pos()) if not self.out_of_pixmap(self.end_point): if self.creating(): self.points.popPoint() self.points.addPoint(self.end_point) # print('self.points:', self.points) # 一个shape # print('self.points.points:', self.points.points) # shape的2个point import copy self.point_four.points = self.points_to_point_four( copy.deepcopy(self.points)) # self.points: <AAA_labelImg_copy.shape.Shape object at 0x000002D5E3726708> # self.points.points: [PyQt5.QtCore.QPoint(99, 195), PyQt5.QtCore.QPoint(384, 389)] # print('self.point_four:', self.point_four) # print('self.point_four.points:', self.point_four.points) self.point_four.close() # 最后一条线闭合 self.shapes.append( self.point_four) # rect的信息就存储在self.shapes中 # print(self.shapes) # 输出为2个对象时: # [<AAA_labelImg_copy.shape.Shape object at 0x000002403CA45508>, # <AAA_labelImg_copy.shape.Shape object at 0x0000024043553FC8>] self.overrideCursor(CURSOR_DEFAULT) # re self.point and self.point_four # self.point self.point_four 作为中间件bottle存在 self.restart_status() self.repaint() # 当以左键结束时,调用newShape # 也就是在你结束框选时,会自动的出现一个labelDialog # 这极大的丰富了交互 if event.button() == Qt.LeftButton: self.newShape.emit() # 信号发射 else: self.overrideCursor(CURSOR_DEFAULT) # 每一次新建rect完成之后,都会自动的将模式转换为edit self.mode_to_edit() except Exception as e: print('Error in mouseReleaseEvent:', e) def deleteSelected(self): # 删除选中的shape if self.selectedShape: shape = self.selectedShape self.shapes.remove(self.selectedShape) self.selectedShape = None self.update() return shape @staticmethod def points_to_point_four(points): # rect的2个顶点变成4个 points_to_four = [ QPointF(points[0].x(), points[0].y()), QPointF(points[1].x(), points[0].y()), QPointF(points[1].x(), points[1].y()), QPointF(points[0].x(), points[1].y()) ] return points_to_four def restart_status(self): self.points = Shape(line_color=self.drawingLineColor) self.point_four = Shape(line_color=self.drawingLineColor) def load_pixmap(self, pixmap): # 加入pixmap(图) self.pixmap = pixmap self.repaint() def set_last_label(self): pass def transformPos(self, point): """Convert from widget-logical coordinates to painter-logical coordinates.""" return point / self.scale - self.offsetToCenter() def offsetToCenter(self): s = self.scale area = super(Canvas, self).size() # print('area:', area) w, h = self.pixmap.width() * s, self.pixmap.height() * s aw, ah = area.width(), area.height() x = (aw - w) / (2 * s) if aw > w else 0 y = (ah - h) / (2 * s) if ah > h else 0 return QPointF(x, y) def paintEvent(self, event): if not self.pixmap: return super(Canvas, self).paintEvent(event) p = self.painter p.begin(self) p.setRenderHint(QPainter.Antialiasing) p.setRenderHint(QPainter.HighQualityAntialiasing) p.setRenderHint(QPainter.SmoothPixmapTransform) # 调整位置和大小 p.scale(self.scale, self.scale) p.translate(self.offsetToCenter()) # 图 p.drawPixmap(0, 0, self.pixmap) # 从(0, 0)开始到自己的大小结束。为了标记 Shape.scale = self.scale # 成品之后 # 让已经创建的rect一直显示在canvas上 # 从第一个rect开始起作用 for shape in self.shapes: # print('self.shapes:', self.shapes) # print('shape:', shape) if shape: shape.paint(p) # 成品之前 # 顶点 if self.points: self.points.paint(p) # rect if self.point_four: self.point_four.paint(p) # Paint rect if (len(self.points) == 2 and self.creating()): # len(self.line) == 2 存放的是两个点 leftTop = self.points[0] rightBottom = self.points[1] rectWidth = rightBottom.x() - leftTop.x() rectHeight = rightBottom.y() - leftTop.y() p.setPen(QColor(0, 0, 255)) # 填充阴影效果 brush = QBrush(Qt.BDiagPattern) p.setBrush(brush) # 实时的画出矩形 p.drawRect(leftTop.x(), leftTop.y(), rectWidth, rectHeight) # 画出点的十字架 # 未实现,可有可无的功能 # if ( # self.creating() # and not self.ten_bottle.isNull() # and not self.outOfPixmap(self.ten_bottle) # ): # print('1') # p.setPen(QColor(0, 0, 0)) # p.drawLine(self.ten_bottle.x(), 0, self.ten_bottle.x(), self.pixmap.height()) # p.drawLine(0, self.ten_bottle.y(), self.pixmap.width(), self.ten_bottle.y()) p.end() def setDrawingShapeToSquare(self, status): self.drawSquare = status def sizeHint(self): return self.minimumSizeHint() def minimumSizeHint(self): if self.pixmap: return self.scale * self.pixmap.size() return super(Canvas, self).minimumSizeHint()
class Canvas(QtWidgets.QWidget): zoomRequest = QtCore.Signal(int, QtCore.QPoint) scrollRequest = QtCore.Signal(int, int) newShape = QtCore.Signal() selectionChanged = QtCore.Signal(list) shapeMoved = QtCore.Signal() drawingPolygon = QtCore.Signal(bool) edgeSelected = QtCore.Signal(bool, object) vertexSelected = QtCore.Signal(bool) CREATE, EDIT = 0, 1 # polygon, rectangle, line, or point _createMode = "polygon" _fill_drawing = False def __init__(self, *args, **kwargs): self.epsilon = kwargs.pop("epsilon", 10.0) self.double_click = kwargs.pop("double_click", "close") if self.double_click not in [None, "close"]: raise ValueError( "Unexpected value for double_click event: {}".format( self.double_click ) ) super(Canvas, self).__init__(*args, **kwargs) # Initialise local state. self.mode = self.EDIT self.shapes = [] self.shapesBackups = [] self.current = None self.selectedShapes = [] # save the selected shapes here self.selectedShapesCopy = [] # self.line represents: # - createMode == 'polygon': edge from last point to current # - createMode == 'rectangle': diagonal line of the rectangle # - createMode == 'line': the line # - createMode == 'point': the point self.line = Shape() self.prevPoint = QtCore.QPoint() self.prevMovePoint = QtCore.QPoint() self.offsets = QtCore.QPoint(), QtCore.QPoint() self.scale = 1.0 self.pixmap = QtGui.QPixmap() self.visible = {} self._hideBackround = False self.hideBackround = False self.hShape = None self.prevhShape = None self.hVertex = None self.prevhVertex = None self.hEdge = None self.prevhEdge = None self.movingShape = False self._painter = QtGui.QPainter() self._cursor = CURSOR_DEFAULT # Menus: # 0: right-click without selection and dragging of shapes # 1: right-click with selection and dragging of shapes self.menus = (QtWidgets.QMenu(), QtWidgets.QMenu()) # Set widget options. self.setMouseTracking(True) self.setFocusPolicy(QtCore.Qt.WheelFocus) def fillDrawing(self): return self._fill_drawing def setFillDrawing(self, value): self._fill_drawing = value @property def createMode(self): return self._createMode @createMode.setter def createMode(self, value): if value not in [ "polygon", "rectangle", "circle", "line", "point", "linestrip", ]: raise ValueError("Unsupported createMode: %s" % value) self._createMode = value def storeShapes(self): shapesBackup = [] for shape in self.shapes: shapesBackup.append(shape.copy()) if len(self.shapesBackups) >= 10: self.shapesBackups = self.shapesBackups[-9:] self.shapesBackups.append(shapesBackup) @property def isShapeRestorable(self): if len(self.shapesBackups) < 2: return False return True def restoreShape(self): if not self.isShapeRestorable: return self.shapesBackups.pop() # latest shapesBackup = self.shapesBackups.pop() self.shapes = shapesBackup self.selectedShapes = [] for shape in self.shapes: shape.selected = False self.repaint() def enterEvent(self, ev): self.overrideCursor(self._cursor) def leaveEvent(self, ev): self.unHighlight() self.restoreCursor() def focusOutEvent(self, ev): self.restoreCursor() def isVisible(self, shape): return self.visible.get(shape, True) def drawing(self): return self.mode == self.CREATE def editing(self): return self.mode == self.EDIT def setEditing(self, value=True): self.mode = self.EDIT if value else self.CREATE if not value: # Create self.unHighlight() self.deSelectShape() def unHighlight(self): if self.hShape: self.hShape.highlightClear() self.update() self.prevhShape = self.hShape self.prevhVertex = self.hVertex self.prevhEdge = self.hEdge self.hShape = self.hVertex = self.hEdge = None def selectedVertex(self): return self.hVertex is not None def mouseMoveEvent(self, ev): """Update line with last point and current coordinates.""" try: if QT5: pos = self.transformPos(ev.localPos()) else: pos = self.transformPos(ev.posF()) except AttributeError: return self.prevMovePoint = pos self.restoreCursor() # Polygon drawing. if self.drawing(): self.line.shape_type = self.createMode self.overrideCursor(CURSOR_DRAW) if not self.current: return if self.outOfPixmap(pos): # Don't allow the user to draw outside the pixmap. # Project the point to the pixmap's edges. pos = self.intersectionPoint(self.current[-1], pos) elif ( len(self.current) > 1 and self.createMode == "polygon" and self.closeEnough(pos, self.current[0]) ): # Attract line to starting point and # colorise to alert the user. pos = self.current[0] self.overrideCursor(CURSOR_POINT) self.current.highlightVertex(0, Shape.NEAR_VERTEX) if self.createMode in ["polygon", "linestrip"]: self.line[0] = self.current[-1] self.line[1] = pos elif self.createMode == "rectangle": self.line.points = [self.current[0], pos] self.line.close() elif self.createMode == "circle": self.line.points = [self.current[0], pos] self.line.shape_type = "circle" elif self.createMode == "line": self.line.points = [self.current[0], pos] self.line.close() elif self.createMode == "point": self.line.points = [self.current[0]] self.line.close() self.repaint() self.current.highlightClear() return # Polygon copy moving. if QtCore.Qt.RightButton & ev.buttons(): if self.selectedShapesCopy and self.prevPoint: self.overrideCursor(CURSOR_MOVE) self.boundedMoveShapes(self.selectedShapesCopy, pos) self.repaint() elif self.selectedShapes: self.selectedShapesCopy = [ s.copy() for s in self.selectedShapes ] self.repaint() return # Polygon/Vertex moving. if QtCore.Qt.LeftButton & ev.buttons(): if self.selectedVertex(): self.boundedMoveVertex(pos) self.repaint() self.movingShape = True elif self.selectedShapes and self.prevPoint: self.overrideCursor(CURSOR_MOVE) self.boundedMoveShapes(self.selectedShapes, pos) self.repaint() self.movingShape = True return # Just hovering over the canvas, 2 possibilities: # - Highlight shapes # - Highlight vertex # Update shape/vertex fill and tooltip value accordingly. self.setToolTip(self.tr("Image")) for shape in reversed([s for s in self.shapes if self.isVisible(s)]): # Look for a nearby vertex to highlight. If that fails, # check if we happen to be inside a shape. index = shape.nearestVertex(pos, self.epsilon / self.scale) index_edge = shape.nearestEdge(pos, self.epsilon / self.scale) if index is not None: if self.selectedVertex(): self.hShape.highlightClear() self.prevhVertex = self.hVertex = index self.prevhShape = self.hShape = shape self.prevhEdge = self.hEdge = index_edge shape.highlightVertex(index, shape.MOVE_VERTEX) self.overrideCursor(CURSOR_POINT) self.setToolTip(self.tr("Click & drag to move point")) self.setStatusTip(self.toolTip()) self.update() break elif shape.containsPoint(pos): if self.selectedVertex(): self.hShape.highlightClear() self.prevhVertex = self.hVertex self.hVertex = None self.prevhShape = self.hShape = shape self.prevhEdge = self.hEdge = index_edge self.setToolTip( self.tr("Click & drag to move shape '%s'") % shape.label ) self.setStatusTip(self.toolTip()) self.overrideCursor(CURSOR_GRAB) self.update() break else: # Nothing found, clear highlights, reset state. self.unHighlight() self.edgeSelected.emit(self.hEdge is not None, self.hShape) self.vertexSelected.emit(self.hVertex is not None) def addPointToEdge(self): shape = self.prevhShape index = self.prevhEdge point = self.prevMovePoint if shape is None or index is None or point is None: return shape.insertPoint(index, point) shape.highlightVertex(index, shape.MOVE_VERTEX) self.hShape = shape self.hVertex = index self.hEdge = None self.movingShape = True def removeSelectedPoint(self): shape = self.prevhShape point = self.prevMovePoint if shape is None or point is None: return index = shape.nearestVertex(point, self.epsilon) shape.removePoint(index) # shape.highlightVertex(index, shape.MOVE_VERTEX) self.hShape = shape self.hVertex = None self.hEdge = None self.movingShape = True # Save changes def mousePressEvent(self, ev): if QT5: pos = self.transformPos(ev.localPos()) else: pos = self.transformPos(ev.posF()) if ev.button() == QtCore.Qt.LeftButton: if self.drawing(): if self.current: # Add point to existing shape. if self.createMode == "polygon": self.current.addPoint(self.line[1]) self.line[0] = self.current[-1] if self.current.isClosed(): self.finalise() elif self.createMode in ["rectangle", "circle", "line"]: assert len(self.current.points) == 1 self.current.points = self.line.points self.finalise() elif self.createMode == "linestrip": self.current.addPoint(self.line[1]) self.line[0] = self.current[-1] if int(ev.modifiers()) == QtCore.Qt.ControlModifier: self.finalise() elif not self.outOfPixmap(pos): # Create new shape. self.current = Shape(shape_type=self.createMode) self.current.addPoint(pos) if self.createMode == "point": self.finalise() else: if self.createMode == "circle": self.current.shape_type = "circle" self.line.points = [pos, pos] self.setHiding() self.drawingPolygon.emit(True) self.update() else: group_mode = int(ev.modifiers()) == QtCore.Qt.ControlModifier self.selectShapePoint(pos, multiple_selection_mode=group_mode) self.prevPoint = pos self.repaint() elif ev.button() == QtCore.Qt.RightButton and self.editing(): group_mode = int(ev.modifiers()) == QtCore.Qt.ControlModifier self.selectShapePoint(pos, multiple_selection_mode=group_mode) self.prevPoint = pos self.repaint() def mouseReleaseEvent(self, ev): if ev.button() == QtCore.Qt.RightButton: menu = self.menus[len(self.selectedShapesCopy) > 0] self.restoreCursor() if ( not menu.exec_(self.mapToGlobal(ev.pos())) and self.selectedShapesCopy ): # Cancel the move by deleting the shadow copy. self.selectedShapesCopy = [] self.repaint() elif ev.button() == QtCore.Qt.LeftButton and self.selectedShapes: self.overrideCursor(CURSOR_GRAB) if ( self.editing() and int(ev.modifiers()) == QtCore.Qt.ShiftModifier ): # Add point to line if: left-click + SHIFT on a line segment self.addPointToEdge() elif ev.button() == QtCore.Qt.LeftButton and self.selectedVertex(): if ( self.editing() and int(ev.modifiers()) == QtCore.Qt.ShiftModifier ): # Delete point if: left-click + SHIFT on a point self.removeSelectedPoint() if self.movingShape and self.hShape: index = self.shapes.index(self.hShape) if ( self.shapesBackups[-1][index].points != self.shapes[index].points ): self.storeShapes() self.shapeMoved.emit() self.movingShape = False def endMove(self, copy): assert self.selectedShapes and self.selectedShapesCopy assert len(self.selectedShapesCopy) == len(self.selectedShapes) if copy: for i, shape in enumerate(self.selectedShapesCopy): self.shapes.append(shape) self.selectedShapes[i].selected = False self.selectedShapes[i] = shape else: for i, shape in enumerate(self.selectedShapesCopy): self.selectedShapes[i].points = shape.points self.selectedShapesCopy = [] self.repaint() self.storeShapes() return True def hideBackroundShapes(self, value): self.hideBackround = value if self.selectedShapes: # Only hide other shapes if there is a current selection. # Otherwise the user will not be able to select a shape. self.setHiding(True) self.repaint() def setHiding(self, enable=True): self._hideBackround = self.hideBackround if enable else False def canCloseShape(self): return self.drawing() and self.current and len(self.current) > 2 def mouseDoubleClickEvent(self, ev): # We need at least 4 points here, since the mousePress handler # adds an extra one before this handler is called. if ( self.double_click == "close" and self.canCloseShape() and len(self.current) > 3 ): self.current.popPoint() self.finalise() def selectShapes(self, shapes): self.setHiding() self.selectionChanged.emit(shapes) self.update() def selectShapePoint(self, point, multiple_selection_mode): """Select the first shape created which contains this point.""" if self.selectedVertex(): # A vertex is marked for selection. index, shape = self.hVertex, self.hShape shape.highlightVertex(index, shape.MOVE_VERTEX) else: for shape in reversed(self.shapes): if self.isVisible(shape) and shape.containsPoint(point): self.calculateOffsets(shape, point) self.setHiding() if multiple_selection_mode: if shape not in self.selectedShapes: self.selectionChanged.emit( self.selectedShapes + [shape] ) else: self.selectionChanged.emit([shape]) return self.deSelectShape() def calculateOffsets(self, shape, point): rect = shape.boundingRect() x1 = rect.x() - point.x() y1 = rect.y() - point.y() x2 = (rect.x() + rect.width() - 1) - point.x() y2 = (rect.y() + rect.height() - 1) - point.y() self.offsets = QtCore.QPoint(x1, y1), QtCore.QPoint(x2, y2) def boundedMoveVertex(self, pos): index, shape = self.hVertex, self.hShape point = shape[index] if self.outOfPixmap(pos): pos = self.intersectionPoint(point, pos) shape.moveVertexBy(index, pos - point) def boundedMoveShapes(self, shapes, pos): if self.outOfPixmap(pos): return False # No need to move o1 = pos + self.offsets[0] if self.outOfPixmap(o1): pos -= QtCore.QPoint(min(0, o1.x()), min(0, o1.y())) o2 = pos + self.offsets[1] if self.outOfPixmap(o2): pos += QtCore.QPoint( min(0, self.pixmap.width() - o2.x()), min(0, self.pixmap.height() - o2.y()), ) # XXX: The next line tracks the new position of the cursor # relative to the shape, but also results in making it # a bit "shaky" when nearing the border and allows it to # go outside of the shape's area for some reason. # self.calculateOffsets(self.selectedShapes, pos) dp = pos - self.prevPoint if dp: for shape in shapes: shape.moveBy(dp) self.prevPoint = pos return True return False def deSelectShape(self): if self.selectedShapes: self.setHiding(False) self.selectionChanged.emit([]) self.update() def deleteSelected(self): deleted_shapes = [] if self.selectedShapes: for shape in self.selectedShapes: self.shapes.remove(shape) deleted_shapes.append(shape) self.storeShapes() self.selectedShapes = [] self.update() return deleted_shapes def copySelectedShapes(self): if self.selectedShapes: self.selectedShapesCopy = [s.copy() for s in self.selectedShapes] self.boundedShiftShapes(self.selectedShapesCopy) self.endMove(copy=True) return self.selectedShapes def boundedShiftShapes(self, shapes): # Try to move in one direction, and if it fails in another. # Give up if both fail. point = shapes[0][0] offset = QtCore.QPoint(2.0, 2.0) self.offsets = QtCore.QPoint(), QtCore.QPoint() self.prevPoint = point if not self.boundedMoveShapes(shapes, point - offset): self.boundedMoveShapes(shapes, point + offset) def paintEvent(self, event): if not self.pixmap: return super(Canvas, self).paintEvent(event) p = self._painter p.begin(self) p.setRenderHint(QtGui.QPainter.Antialiasing) p.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) p.setRenderHint(QtGui.QPainter.SmoothPixmapTransform) p.scale(self.scale, self.scale) p.translate(self.offsetToCenter()) p.drawPixmap(0, 0, self.pixmap) Shape.scale = self.scale for shape in self.shapes: if (shape.selected or not self._hideBackround) and self.isVisible( shape ): shape.fill = shape.selected or shape == self.hShape shape.paint(p) if self.current: self.current.paint(p) self.line.paint(p) if self.selectedShapesCopy: for s in self.selectedShapesCopy: s.paint(p) if ( self.fillDrawing() and self.createMode == "polygon" and self.current is not None and len(self.current.points) >= 2 ): drawing_shape = self.current.copy() drawing_shape.addPoint(self.line[1]) drawing_shape.fill = True drawing_shape.paint(p) p.end() def transformPos(self, point): """Convert from widget-logical coordinates to painter-logical ones.""" return point / self.scale - self.offsetToCenter() def offsetToCenter(self): s = self.scale area = super(Canvas, self).size() if not self.pixmap: return QtCore.QPoint(0, 0) w, h = self.pixmap.width() * s, self.pixmap.height() * s aw, ah = area.width(), area.height() x = (aw - w) / (2 * s) if aw > w else 0 y = (ah - h) / (2 * s) if ah > h else 0 return QtCore.QPoint(x, y) def outOfPixmap(self, p): if not self.pixmap: return True w, h = self.pixmap.width(), self.pixmap.height() return not (0 <= p.x() <= w - 1 and 0 <= p.y() <= h - 1) def finalise(self): assert self.current self.current.close() self.shapes.append(self.current) self.storeShapes() self.current = None self.setHiding(False) self.newShape.emit() self.update() def closeEnough(self, p1, p2): # d = distance(p1 - p2) # m = (p1-p2).manhattanLength() # print "d %.2f, m %d, %.2f" % (d, m, d - m) # divide by scale to allow more precision when zoomed in return utils.distance(p1 - p2) < (self.epsilon / self.scale) def intersectionPoint(self, p1, p2): # Cycle through each image edge in clockwise fashion, # and find the one intersecting the current line segment. # http://paulbourke.net/geometry/lineline2d/ size = self.pixmap.size() points = [ (0, 0), (size.width() - 1, 0), (size.width() - 1, size.height() - 1), (0, size.height() - 1), ] # x1, y1 should be in the pixmap, x2, y2 should be out of the pixmap x1 = min(max(p1.x(), 0), size.width() - 1) y1 = min(max(p1.y(), 0), size.height() - 1) x2, y2 = p2.x(), p2.y() d, i, (x, y) = min(self.intersectingEdges((x1, y1), (x2, y2), points)) x3, y3 = points[i] x4, y4 = points[(i + 1) % 4] if (x, y) == (x1, y1): # Handle cases where previous point is on one of the edges. if x3 == x4: return QtCore.QPoint(x3, min(max(0, y2), max(y3, y4))) else: # y3 == y4 return QtCore.QPoint(min(max(0, x2), max(x3, x4)), y3) return QtCore.QPoint(x, y) def intersectingEdges(self, point1, point2, points): """Find intersecting edges. For each edge formed by `points', yield the intersection with the line segment `(x1,y1) - (x2,y2)`, if it exists. Also return the distance of `(x2,y2)' to the middle of the edge along with its index, so that the one closest can be chosen. """ (x1, y1) = point1 (x2, y2) = point2 for i in range(4): x3, y3 = points[i] x4, y4 = points[(i + 1) % 4] denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1) nua = (x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3) nub = (x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3) if denom == 0: # This covers two cases: # nua == nub == 0: Coincident # otherwise: Parallel continue ua, ub = nua / denom, nub / denom if 0 <= ua <= 1 and 0 <= ub <= 1: x = x1 + ua * (x2 - x1) y = y1 + ua * (y2 - y1) m = QtCore.QPoint((x3 + x4) / 2, (y3 + y4) / 2) d = utils.distance(m - QtCore.QPoint(x2, y2)) yield d, i, (x, y) # These two, along with a call to adjustSize are required for the # scroll area. def sizeHint(self): return self.minimumSizeHint() def minimumSizeHint(self): if self.pixmap: return self.scale * self.pixmap.size() return super(Canvas, self).minimumSizeHint() def wheelEvent(self, ev): if QT5: mods = ev.modifiers() delta = ev.angleDelta() if QtCore.Qt.ControlModifier == int(mods): # with Ctrl/Command key # zoom self.zoomRequest.emit(delta.y(), ev.pos()) else: # scroll self.scrollRequest.emit(delta.x(), QtCore.Qt.Horizontal) self.scrollRequest.emit(delta.y(), QtCore.Qt.Vertical) else: if ev.orientation() == QtCore.Qt.Vertical: mods = ev.modifiers() if QtCore.Qt.ControlModifier == int(mods): # with Ctrl/Command key self.zoomRequest.emit(ev.delta(), ev.pos()) else: self.scrollRequest.emit( ev.delta(), QtCore.Qt.Horizontal if (QtCore.Qt.ShiftModifier == int(mods)) else QtCore.Qt.Vertical, ) else: self.scrollRequest.emit(ev.delta(), QtCore.Qt.Horizontal) ev.accept() def keyPressEvent(self, ev): key = ev.key() if key == QtCore.Qt.Key_Escape and self.current: self.current = None self.drawingPolygon.emit(False) self.update() elif key == QtCore.Qt.Key_Return and self.canCloseShape(): self.finalise() def setLastLabel(self, text, flags): assert text self.shapes[-1].label = text self.shapes[-1].flags = flags self.shapesBackups.pop() self.storeShapes() return self.shapes[-1] def undoLastLine(self): assert self.shapes self.current = self.shapes.pop() self.current.setOpen() if self.createMode in ["polygon", "linestrip"]: self.line.points = [self.current[-1], self.current[0]] elif self.createMode in ["rectangle", "line", "circle"]: self.current.points = self.current.points[0:1] elif self.createMode == "point": self.current = None self.drawingPolygon.emit(True) def undoLastPoint(self): if not self.current or self.current.isClosed(): return self.current.popPoint() if len(self.current) > 0: self.line[0] = self.current[-1] else: self.current = None self.drawingPolygon.emit(False) self.repaint() def loadPixmap(self, pixmap, clear_shapes=True): self.pixmap = pixmap if clear_shapes: self.shapes = [] self.repaint() def loadShapes(self, shapes, replace=True): if replace: self.shapes = list(shapes) else: self.shapes.extend(shapes) self.storeShapes() self.current = None self.hShape = None self.hVertex = None self.hEdge = None self.repaint() def setShapeVisible(self, shape, value): self.visible[shape] = value self.repaint() def overrideCursor(self, cursor): self.restoreCursor() self._cursor = cursor QtWidgets.QApplication.setOverrideCursor(cursor) def restoreCursor(self): QtWidgets.QApplication.restoreOverrideCursor() def resetState(self): self.restoreCursor() self.pixmap = None self.shapesBackups = [] self.shapes = [] self.update()