Merge pull request #710 from campagnola/roi-mouse-interaction

Roi mouse interaction
This commit is contained in:
Luke Campagnola 2018-06-15 20:30:37 -07:00 committed by GitHub
commit 00cccb556d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 174 additions and 59 deletions

View File

@ -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)

View File

@ -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)