Implement handle-free scale/rotation mouse interaction for ROI

This commit is contained in:
Luke Campagnola 2018-06-15 13:36:33 -07:00
parent 2d2e548f8e
commit e78693631b

View File

@ -68,13 +68,18 @@ class ROI(GraphicsObject):
to be integer multiples of *snapSize* when being resized to be integer multiples of *snapSize* when being resized
by the user. Default is False. by the user. Default is False.
rotateSnap (bool) If True, the ROI angle is forced to a multiple of 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 parent (QGraphicsItem) The graphics item parent of this ROI. It
is generally not necessary to specify the parent. is generally not necessary to specify the parent.
pen (QPen or argument to pg.mkPen) The pen to use when drawing pen (QPen or argument to pg.mkPen) The pen to use when drawing
the shape of the ROI. the shape of the ROI.
movable (bool) If True, the ROI can be moved by dragging anywhere movable (bool) If True, the ROI can be moved by dragging anywhere
inside the ROI. Default is True. 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
lockAspect (bool) If True, the aspect ratio of the ROI is locked during
mouse interaction.
removable (bool) If True, the ROI will be given a context menu with removable (bool) If True, the ROI will be given a context menu with
an option to remove the ROI. The ROI emits an option to remove the ROI. The ROI emits
sigRemoveRequested when this menu action is selected. sigRemoveRequested when this menu action is selected.
@ -112,14 +117,19 @@ class ROI(GraphicsObject):
sigClicked = QtCore.Signal(object, object) sigClicked = QtCore.Signal(object, object)
sigRemoveRequested = QtCore.Signal(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,
lockAspect=False, removable=False):
GraphicsObject.__init__(self, parent) GraphicsObject.__init__(self, parent)
self.setAcceptedMouseButtons(QtCore.Qt.NoButton) self.setAcceptedMouseButtons(QtCore.Qt.NoButton)
pos = Point(pos) pos = Point(pos)
size = Point(size) size = Point(size)
self.aspectLocked = False self.aspectLocked = False
self.translatable = movable self.translatable = movable
self.rotateAllowed = True self.rotatable = rotatable
self.resizable = resizable
self.lockAspect = lockAspect
self.removable = removable self.removable = removable
self.menu = None self.menu = None
@ -146,7 +156,9 @@ class ROI(GraphicsObject):
self.snapSize = snapSize self.snapSize = snapSize
self.translateSnap = translateSnap self.translateSnap = translateSnap
self.rotateSnap = rotateSnap self.rotateSnap = rotateSnap
self.rotateSnapAngle = 15.0
self.scaleSnap = scaleSnap self.scaleSnap = scaleSnap
self.scaleSnapSize = snapSize
# Implement mouse handling in a separate class to allow easier customization # Implement mouse handling in a separate class to allow easier customization
self.mouseDragHandler = MouseDragHandler(self) self.mouseDragHandler = MouseDragHandler(self)
@ -249,45 +261,87 @@ class ROI(GraphicsObject):
if update: if update:
self.stateChanged(finish=finish) self.stateChanged(finish=finish)
def setSize(self, size, update=True, finish=True): def setSize(self, size, center=None, centerLocal=None, snap=False, 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. 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): if update not in (True, False):
raise TypeError("update argument must be bool") raise TypeError("update argument must be bool")
size = Point(size) size = Point(size)
if centerLocal is not None:
center = Point(centerLocal) / self.state['size']
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.prepareGeometryChange()
self.state['size'] = size self.state['size'] = size
if update: if update:
self.stateChanged(finish=finish) self.stateChanged(finish=finish)
def setAngle(self, angle, update=True, finish=True): def setAngle(self, angle, center=None, centerLocal=None, snap=False, 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. 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): if update not in (True, False):
raise TypeError("update argument must be bool") raise TypeError("update argument must be bool")
if snap is True:
angle = round(angle / self.rotateSnapAngle) * self.rotateSnapAngle
self.state['angle'] = angle self.state['angle'] = angle
tr = QtGui.QTransform() tr = QtGui.QTransform() # note: only rotation is contained in the transform
tr.rotate(angle) 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) self.setTransform(tr)
if update: if update:
self.stateChanged(finish=finish) 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*. Resize the ROI by scaling relative to *center*.
See setPos() for an explanation of the *update* and *finish* arguments. 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 newSize = self.state['size'] * s
c1 = self.mapToParent(Point(center) * newSize) self.setSize(newSize, center=center, centerLocal=centerLocal, snap=snap, update=update, finish=finish)
newPos = self.state['pos'] + c - c1
self.setSize(newSize, update=False)
self.setPos(newPos, update=update, finish=finish)
def translate(self, *args, **kargs): def translate(self, *args, **kargs):
""" """
@ -339,14 +393,22 @@ class ROI(GraphicsObject):
finish = kargs.get('finish', True) finish = kargs.get('finish', True)
self.setPos(newState['pos'], update=update, finish=finish) 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. 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): def handleMoveStarted(self):
self.preMoveState = self.getState() self.preMoveState = self.getState()
@ -701,6 +763,16 @@ class ROI(GraphicsObject):
else: else:
ev.ignore() 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): def cancelMove(self):
self.isMoving = False self.isMoving = False
self.setState(self.preMoveState) self.setState(self.preMoveState)
@ -803,7 +875,7 @@ class ROI(GraphicsObject):
if h['type'] == 'rf': if h['type'] == 'rf':
self.freeHandleMoved = True self.freeHandleMoved = True
if not self.rotateAllowed: if not self.rotatable:
return return
## If the handle is directly over its center point, we can't compute an angle. ## If the handle is directly over its center point, we can't compute an angle.
try: try:
@ -817,7 +889,7 @@ class ROI(GraphicsObject):
if ang is None: ## this should never happen.. if ang is None: ## this should never happen..
return return
if self.rotateSnap or (modifiers & QtCore.Qt.ControlModifier): 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 ## create rotation transform
tr = QtGui.QTransform() tr = QtGui.QTransform()
@ -859,7 +931,7 @@ class ROI(GraphicsObject):
if ang is None: if ang is None:
return return
if self.rotateSnap or (modifiers & QtCore.Qt.ControlModifier): 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]) hs = abs(h['pos'][scaleAxis] - c[scaleAxis])
newState['size'][scaleAxis] = lp1.length() / hs newState['size'][scaleAxis] = lp1.length() / hs
@ -1363,6 +1435,12 @@ class MouseDragHandler(object):
""" """
def __init__(self, roi): def __init__(self, roi):
self.roi = 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
def mouseDragEvent(self, ev): def mouseDragEvent(self, ev):
roi = self.roi roi = self.roi
@ -1370,29 +1448,48 @@ class MouseDragHandler(object):
if ev.isStart(): if ev.isStart():
if ev.button() == QtCore.Qt.LeftButton: if ev.button() == QtCore.Qt.LeftButton:
roi.setSelected(True) roi.setSelected(True)
if roi.translatable: mods = ev.modifiers() & ~self.snapModifier
roi.isMoving = True if roi.translatable and mods == self.translateModifier:
roi.preMoveState = roi.getState() self.dragMode = 'translate'
roi.cursorOffset = roi.pos() - roi.mapToParent(ev.buttonDownPos()) elif roi.rotatable and mods == self.rotateModifier:
roi.sigRegionChangeStarted.emit(roi) 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() ev.accept()
else: else:
ev.ignore() ev.ignore()
else:
self.dragMode = None
ev.ignore()
elif ev.isFinish():
if roi.translatable: if ev.isFinish() and self.dragMode is not None:
if roi.isMoving: roi._moveFinished()
roi.stateChangeFinished()
roi.isMoving = False
return return
if roi.translatable and roi.isMoving and ev.buttons() == QtCore.Qt.LeftButton: # roi.isMoving becomes False if the move was cancelled by right-click
snap = True if (ev.modifiers() & QtCore.Qt.ControlModifier) else None if not roi.isMoving or self.dragMode is None:
newPos = roi.mapToParent(ev.pos()) + roi.cursorOffset return
roi.translate(newPos - roi.pos(), snap=snap, finish=False)
# elif self.rotatable and self.isMoving and (ev.modifiers() & QtCore.Qt.AltModifier):
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 = (ev.scenePos() - ev.buttonDownScenePos()).y()
roi.setAngle(self.startState['angle'] + diff, centerLocal=ev.buttonDownPos(), snap=snap, finish=False)
elif self.dragMode == 'scale':
diff = 1.01 ** -(ev.scenePos() - ev.buttonDownScenePos()).y()
roi.setSize(Point(self.startState['size']) * diff, centerLocal=ev.buttonDownPos(), snap=snap, finish=False)
class TestROI(ROI): class TestROI(ROI):