diff --git a/examples/ROIExamples.py b/examples/ROIExamples.py index 2b922359..fe3e4db8 100644 --- a/examples/ROIExamples.py +++ b/examples/ROIExamples.py @@ -138,7 +138,7 @@ label4 = w4.addLabel(text, row=0, col=0) v4 = w4.addViewBox(row=1, col=0, lockAspect=True) g = pg.GridItem() v4.addItem(g) -r4 = pg.ROI([0,0], [100,100], removable=True) +r4 = pg.ROI([0,0], [100,100], resizable=False, removable=True) r4.addRotateHandle([1,0], [0.5, 0.5]) r4.addRotateHandle([0,1], [0.5, 0.5]) img4 = pg.ImageItem(arr) diff --git a/pyqtgraph/graphicsItems/ROI.py b/pyqtgraph/graphicsItems/ROI.py index 9682b6b3..7568d000 100644 --- a/pyqtgraph/graphicsItems/ROI.py +++ b/pyqtgraph/graphicsItems/ROI.py @@ -42,9 +42,7 @@ class ROI(GraphicsObject): rotate/translate/scale handles. ROIs can be customized to have a variety of shapes (by subclassing or using any of the built-in subclasses) and any combination of draggable handles - that allow the user to manipulate the ROI. - - + that allow the user to manipulate the ROI. ================ =========================================================== **Arguments** @@ -68,13 +66,16 @@ class ROI(GraphicsObject): to be integer multiples of *snapSize* when being resized by the user. Default is False. rotateSnap (bool) If True, the ROI angle is forced to a multiple of - 15 degrees when rotated by the user. Default is False. + the ROI's snap angle (default is 15 degrees) when rotated + by the user. Default is False. parent (QGraphicsItem) The graphics item parent of this ROI. It is generally not necessary to specify the parent. pen (QPen or argument to pg.mkPen) The pen to use when drawing the shape of the ROI. movable (bool) If True, the ROI can be moved by dragging anywhere inside the ROI. Default is True. + rotatable (bool) If True, the ROI can be rotated by mouse drag + ALT + resizable (bool) If True, the ROI can be resized by mouse drag + SHIFT removable (bool) If True, the ROI will be given a context menu with an option to remove the ROI. The ROI emits sigRemoveRequested when this menu action is selected. @@ -112,14 +113,18 @@ class ROI(GraphicsObject): sigClicked = QtCore.Signal(object, object) sigRemoveRequested = QtCore.Signal(object) - def __init__(self, pos, size=Point(1, 1), angle=0.0, invertible=False, maxBounds=None, snapSize=1.0, scaleSnap=False, translateSnap=False, rotateSnap=False, parent=None, pen=None, movable=True, removable=False): + def __init__(self, pos, size=Point(1, 1), angle=0.0, invertible=False, maxBounds=None, + snapSize=1.0, scaleSnap=False, translateSnap=False, rotateSnap=False, + parent=None, pen=None, movable=True, rotatable=True, resizable=True, + removable=False): GraphicsObject.__init__(self, parent) self.setAcceptedMouseButtons(QtCore.Qt.NoButton) pos = Point(pos) size = Point(size) self.aspectLocked = False self.translatable = movable - self.rotateAllowed = True + self.rotatable = rotatable + self.resizable = resizable self.removable = removable self.menu = None @@ -146,7 +151,12 @@ class ROI(GraphicsObject): self.snapSize = snapSize self.translateSnap = translateSnap self.rotateSnap = rotateSnap + self.rotateSnapAngle = 15.0 self.scaleSnap = scaleSnap + self.scaleSnapSize = snapSize + + # Implement mouse handling in a separate class to allow easier customization + self.mouseDragHandler = MouseDragHandler(self) def getState(self): return self.stateCopy() @@ -246,45 +256,93 @@ class ROI(GraphicsObject): if update: self.stateChanged(finish=finish) - def setSize(self, size, update=True, finish=True): - """Set the size of the ROI. May be specified as a QPoint, Point, or list of two values. - See setPos() for an explanation of the update and finish arguments. + def setSize(self, size, center=None, centerLocal=None, snap=False, update=True, finish=True): + """ + Set the ROI's size. + + =============== ========================================================================== + **Arguments** + size (Point | QPointF | sequence) The final size of the ROI + center (None | Point) Optional center point around which the ROI is scaled, + expressed as [0-1, 0-1] over the size of the ROI. + centerLocal (None | Point) Same as *center*, but the position is expressed in the + local coordinate system of the ROI + snap (bool) If True, the final size is snapped to the nearest increment (see + ROI.scaleSnapSize) + update (bool) See setPos() + finish (bool) See setPos() + =============== ========================================================================== """ if update not in (True, False): raise TypeError("update argument must be bool") size = Point(size) + if snap: + size[0] = round(size[0] / self.scaleSnapSize) * self.scaleSnapSize + size[1] = round(size[1] / self.scaleSnapSize) * self.scaleSnapSize + + if centerLocal is not None: + oldSize = Point(self.state['size']) + oldSize[0] = 1 if oldSize[0] == 0 else oldSize[0] + oldSize[1] = 1 if oldSize[1] == 0 else oldSize[1] + center = Point(centerLocal) / oldSize + + if center is not None: + center = Point(center) + c = self.mapToParent(Point(center) * self.state['size']) + c1 = self.mapToParent(Point(center) * size) + newPos = self.state['pos'] + c - c1 + self.setPos(newPos, update=False, finish=False) + self.prepareGeometryChange() self.state['size'] = size if update: self.stateChanged(finish=finish) - def setAngle(self, angle, update=True, finish=True): - """Set the angle of rotation (in degrees) for this ROI. - See setPos() for an explanation of the update and finish arguments. + def setAngle(self, angle, center=None, centerLocal=None, snap=False, update=True, finish=True): + """ + Set the ROI's rotation angle. + + =============== ========================================================================== + **Arguments** + angle (float) The final ROI angle in degrees + center (None | Point) Optional center point around which the ROI is rotated, + expressed as [0-1, 0-1] over the size of the ROI. + centerLocal (None | Point) Same as *center*, but the position is expressed in the + local coordinate system of the ROI + snap (bool) If True, the final ROI angle is snapped to the nearest increment + (default is 15 degrees; see ROI.rotateSnapAngle) + update (bool) See setPos() + finish (bool) See setPos() + =============== ========================================================================== """ if update not in (True, False): raise TypeError("update argument must be bool") + + if snap is True: + angle = round(angle / self.rotateSnapAngle) * self.rotateSnapAngle + self.state['angle'] = angle - tr = QtGui.QTransform() + tr = QtGui.QTransform() # note: only rotation is contained in the transform tr.rotate(angle) + if center is not None: + centerLocal = Point(center) * self.state['size'] + if centerLocal is not None: + centerLocal = Point(centerLocal) + # rotate to new angle, keeping a specific point anchored as the center of rotation + cc = self.mapToParent(centerLocal) - (tr.map(centerLocal) + self.state['pos']) + self.translate(cc, update=False) + self.setTransform(tr) if update: self.stateChanged(finish=finish) - def scale(self, s, center=[0,0], update=True, finish=True): + def scale(self, s, center=None, centerLocal=None, snap=False, update=True, finish=True): """ Resize the ROI by scaling relative to *center*. See setPos() for an explanation of the *update* and *finish* arguments. """ - c = self.mapToParent(Point(center) * self.state['size']) - self.prepareGeometryChange() newSize = self.state['size'] * s - c1 = self.mapToParent(Point(center) * newSize) - newPos = self.state['pos'] + c - c1 - - self.setSize(newSize, update=False) - self.setPos(newPos, update=update, finish=finish) - + self.setSize(newSize, center=center, centerLocal=centerLocal, snap=snap, update=update, finish=finish) def translate(self, *args, **kargs): """ @@ -336,14 +394,22 @@ class ROI(GraphicsObject): finish = kargs.get('finish', True) self.setPos(newState['pos'], update=update, finish=finish) - def rotate(self, angle, update=True, finish=True): + def rotate(self, angle, center=None, snap=False, update=True, finish=True): """ Rotate the ROI by *angle* degrees. - Also accepts *update* and *finish* arguments (see setPos() for a - description of these). + =============== ========================================================================== + **Arguments** + angle (float) The angle in degrees to rotate + center (None | Point) Optional center point around which the ROI is rotated, in + the local coordinate system of the ROI + snap (bool) If True, the final ROI angle is snapped to the nearest increment + (default is 15 degrees; see ROI.rotateSnapAngle) + update (bool) See setPos() + finish (bool) See setPos() + =============== ========================================================================== """ - self.setAngle(self.angle()+angle, update=update, finish=finish) + self.setAngle(self.angle()+angle, center=center, snap=snap, update=update, finish=finish) def handleMoveStarted(self): self.preMoveState = self.getState() @@ -683,34 +749,8 @@ class ROI(GraphicsObject): QtCore.QTimer.singleShot(0, lambda: self.sigRemoveRequested.emit(self)) def mouseDragEvent(self, ev): - if ev.isStart(): - #p = ev.pos() - #if not self.isMoving and not self.shape().contains(p): - #ev.ignore() - #return - if ev.button() == QtCore.Qt.LeftButton: - self.setSelected(True) - if self.translatable: - self.isMoving = True - self.preMoveState = self.getState() - self.cursorOffset = self.pos() - self.mapToParent(ev.buttonDownPos()) - self.sigRegionChangeStarted.emit(self) - ev.accept() - else: - ev.ignore() + self.mouseDragHandler.mouseDragEvent(ev) - elif ev.isFinish(): - if self.translatable: - if self.isMoving: - self.stateChangeFinished() - self.isMoving = False - return - - if self.translatable and self.isMoving and ev.buttons() == QtCore.Qt.LeftButton: - snap = True if (ev.modifiers() & QtCore.Qt.ControlModifier) else None - newPos = self.mapToParent(ev.pos()) + self.cursorOffset - self.translate(newPos - self.pos(), snap=snap, finish=False) - def mouseClickEvent(self, ev): if ev.button() == QtCore.Qt.RightButton and self.isMoving: ev.accept() @@ -724,6 +764,16 @@ class ROI(GraphicsObject): else: ev.ignore() + def _moveStarted(self): + self.isMoving = True + self.preMoveState = self.getState() + self.sigRegionChangeStarted.emit(self) + + def _moveFinished(self): + if self.isMoving: + self.stateChangeFinished() + self.isMoving = False + def cancelMove(self): self.isMoving = False self.setState(self.preMoveState) @@ -777,8 +827,8 @@ class ROI(GraphicsObject): ## snap if self.scaleSnap or (modifiers & QtCore.Qt.ControlModifier): - lp1[0] = round(lp1[0] / self.snapSize) * self.snapSize - lp1[1] = round(lp1[1] / self.snapSize) * self.snapSize + lp1[0] = round(lp1[0] / self.scaleSnapSize) * self.scaleSnapSize + lp1[1] = round(lp1[1] / self.scaleSnapSize) * self.scaleSnapSize ## preserve aspect ratio (this can override snapping) if h['lockAspect'] or (modifiers & QtCore.Qt.AltModifier): @@ -826,7 +876,7 @@ class ROI(GraphicsObject): if h['type'] == 'rf': self.freeHandleMoved = True - if not self.rotateAllowed: + if not self.rotatable: return ## If the handle is directly over its center point, we can't compute an angle. try: @@ -840,7 +890,7 @@ class ROI(GraphicsObject): if ang is None: ## this should never happen.. return if self.rotateSnap or (modifiers & QtCore.Qt.ControlModifier): - ang = round(ang / 15.) * 15. ## 180/12 = 15 + ang = round(ang / self.rotateSnapAngle) * self.rotateSnapAngle ## create rotation transform tr = QtGui.QTransform() @@ -882,7 +932,7 @@ class ROI(GraphicsObject): if ang is None: return if self.rotateSnap or (modifiers & QtCore.Qt.ControlModifier): - ang = round(ang / 15.) * 15. + ang = round(ang / self.rotateSnapAngle) * self.rotateSnapAngle hs = abs(h['pos'][scaleAxis] - c[scaleAxis]) newState['size'][scaleAxis] = lp1.length() / hs @@ -1381,6 +1431,71 @@ class Handle(UIGraphicsItem): self.update() +class MouseDragHandler(object): + """Implements default mouse drag behavior for ROI (not for ROI handles). + """ + def __init__(self, roi): + self.roi = roi + self.dragMode = None + self.startState = None + self.snapModifier = QtCore.Qt.ControlModifier + self.translateModifier = QtCore.Qt.NoModifier + self.rotateModifier = QtCore.Qt.AltModifier + self.scaleModifier = QtCore.Qt.ShiftModifier + self.rotateSpeed = 0.7 + self.scaleSpeed = 1.01 + + def mouseDragEvent(self, ev): + roi = self.roi + + if ev.isStart(): + if ev.button() == QtCore.Qt.LeftButton: + roi.setSelected(True) + mods = ev.modifiers() & ~self.snapModifier + if roi.translatable and mods == self.translateModifier: + self.dragMode = 'translate' + elif roi.rotatable and mods == self.rotateModifier: + self.dragMode = 'rotate' + elif roi.resizable and mods == self.scaleModifier: + self.dragMode = 'scale' + else: + self.dragMode = None + + if self.dragMode is not None: + roi._moveStarted() + self.startPos = roi.mapToParent(ev.buttonDownPos()) + self.startState = roi.saveState() + self.cursorOffset = roi.pos() - self.startPos + ev.accept() + else: + ev.ignore() + else: + self.dragMode = None + ev.ignore() + + + if ev.isFinish() and self.dragMode is not None: + roi._moveFinished() + return + + # roi.isMoving becomes False if the move was cancelled by right-click + if not roi.isMoving or self.dragMode is None: + return + + snap = True if (ev.modifiers() & self.snapModifier) else None + pos = roi.mapToParent(ev.pos()) + if self.dragMode == 'translate': + newPos = pos + self.cursorOffset + roi.translate(newPos - roi.pos(), snap=snap, finish=False) + elif self.dragMode == 'rotate': + diff = self.rotateSpeed * (ev.scenePos() - ev.buttonDownScenePos()).x() + angle = self.startState['angle'] - diff + roi.setAngle(angle, centerLocal=ev.buttonDownPos(), snap=snap, finish=False) + elif self.dragMode == 'scale': + diff = self.scaleSpeed ** -(ev.scenePos() - ev.buttonDownScenePos()).y() + roi.setSize(Point(self.startState['size']) * diff, centerLocal=ev.buttonDownPos(), snap=snap, finish=False) + + class TestROI(ROI): def __init__(self, pos, size, **args): ROI.__init__(self, pos, size, **args)