将鼠标运动功能添加到基于 PyQt 的贝塞尔抽屉

Add Mouse Motion functionality to PyQt based Bezier Drawer

我正在尝试修改下面的代码以接受鼠标事件(单击、拖动、释放),以便可以选择和移动控制点,从而改变曲线。我不确定从哪里开始,有什么建议吗?控制点标记为红点,曲线为蓝色。

这基本上可以让我在 gui 中修改曲线。任何参考也将不胜感激。

import sys
import random
import functools

from PyQt5 import QtWidgets, QtGui, QtCore

@functools.lru_cache(maxsize=100)
def factorial(n):
    prod = 1
    for i in range(1,n+1):
        prod *= i
    return prod

def randPt(minv, maxv):
    return (random.randint(minv, maxv), random.randint(minv, maxv))

def B(i,n,u):
    val = factorial(n)/(factorial(i)*factorial(n-i))
    return val * (u**i) * ((1-u)**(n-i))

def C(u, pts):
    x = 0
    y = 0
    n = len(pts)-1
    for i in range(n+1):
        binu = B(i,n,u)
        x += binu * pts[i][0]
        y += binu * pts[i][1]
    return (x, y)

class BezierDrawer(QtWidgets.QWidget):
  
    def __init__(self):
        super(BezierDrawer, self).__init__()

        self.setGeometry(300, 300, 1500,1000)
        self.setWindowTitle('Bezier Curves')

    def paintEvent(self, e):
      
        qp = QtGui.QPainter()
        qp.begin(self)
        qp.setRenderHints(QtGui.QPainter.Antialiasing, True)
        self.doDrawing(qp)        
        qp.end()
        
    def doDrawing(self, qp):

        blackPen = QtGui.QPen(QtCore.Qt.black, 3, QtCore.Qt.DashLine)
        redPen = QtGui.QPen(QtCore.Qt.red, 30, QtCore.Qt.DashLine)
        bluePen = QtGui.QPen(QtCore.Qt.blue, 3, QtCore.Qt.DashLine)
        greenPen = QtGui.QPen(QtCore.Qt.green, 3, QtCore.Qt.DashLine)
        redBrush = QtGui.QBrush(QtCore.Qt.red)

        steps = 400
        min_t = 0.0
        max_t = 1.0

        dt = (max_t - min_t)/steps
        # controlPts = [randPt(0,1000) for i in range(6)]
        # controlPts.append(controlPts[1])
        # controlPts.append(controlPts[0])
        controlPts = [(500,500), (600,700), (600,550), (700,500),(700,500), (800,400), (1000,200), (1000,500)]
        oldPt = controlPts[0]
        pn = 1
        qp.setPen(redPen)
        qp.setBrush(redBrush)
        qp.drawEllipse(oldPt[0]-3, oldPt[1]-3, 6,6)

        #qp.drawText(oldPt[0]+5, oldPt[1]-3, '{}'.format(pn))
        for pt in controlPts[1:]:
            pn+=1
            qp.setPen(blackPen)
            
            qp.drawLine(oldPt[0],oldPt[1],pt[0],pt[1])
            
            qp.setPen(redPen)
            qp.drawEllipse(pt[0]-3, pt[1]-3, 6,6)

            #xv=qp.drawText(pt[0]+5, pt[1]-3, '{}'.format(pn))
            #xv.setTextWidth(3)
            oldPt = pt
            
        qp.setPen(bluePen)
        oldPt = controlPts[0]
        for i in range(steps+1):
            t = dt*i
            pt = C(t, controlPts)
            qp.drawLine(oldPt[0],oldPt[1], pt[0],pt[1])
            oldPt = pt

def main(args):
    app = QtWidgets.QApplication(sys.argv)
    ex = BezierDrawer()
    ex.show()
    app.exec_()
    
if __name__=='__main__':
    main(sys.argv[1:])

正如建议的那样,对于更“严肃”的应用程序,您应该选择 GraphicsView
然而,您的应用程序几乎通过简单的绘图就完成了。随心所欲地修改它并没有什么大不了的。

我对您的代码做了一些改动。您必须将控制点列表作为 BezierDrawer 的属性。然后您可以使用事件:mousePressEventmouseMoveEventmouseReleaseEvent 与控制点进行交互。

首先我们需要找出,如果点击了 mousePressEvent 中的任何点,然后在拖动时更新它的位置。点位置的每次更改都必须以 update 方法结束,以重新绘制小部件。

这里是修改后的代码:

import functools
import random
import sys

from PyQt5 import QtWidgets, QtGui, QtCore


@functools.lru_cache(maxsize=100)
def factorial(n):
    prod = 1
    for i in range(1, n + 1):
        prod *= i
    return prod


def randPt(minv, maxv):
    return (random.randint(minv, maxv), random.randint(minv, maxv))


def B(i, n, u):
    val = factorial(n) / (factorial(i) * factorial(n - i))
    return val * (u ** i) * ((1 - u) ** (n - i))


def C(u, pts):
    x = 0
    y = 0
    n = len(pts) - 1
    for i in range(n + 1):
        binu = B(i, n, u)
        x += binu * pts[i][0]
        y += binu * pts[i][1]
    return (x, y)


class BezierDrawer(QtWidgets.QWidget):

    def __init__(self):
        super(BezierDrawer, self).__init__()
        # Dragged point index
        self.dragged_point = None
        # List of control points
        self.controlPts = [(500, 500), (600, 700), (600, 550), (700, 500), (800, 400), (1000, 200),
                           (1000, 500)]
        self.setGeometry(300, 300, 1500, 1000)
        self.setWindowTitle('Bezier Curves')

    def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None:
        """Get through all control points and find out if mouse clicked on it"""
        for control_point, (x, y) in enumerate(self.controlPts):
            if a0.x() - 15 <= x <= a0.x() + 15 and a0.y() - 15 <= y <= a0.y() + 15:
                self.dragged_point = control_point
                return

    def mouseMoveEvent(self, a0: QtGui.QMouseEvent) -> None:
        """If any point is dragged, change its position and repaint scene"""
        if self.dragged_point is not None:
            self.controlPts[self.dragged_point] = (a0.x(), a0.y())
            self.update()

    def mouseReleaseEvent(self, a0: QtGui.QMouseEvent) -> None:
        """Release dragging point and repaint scene again"""
        self.dragged_point = None
        self.update()

    def paintEvent(self, e):

        qp = QtGui.QPainter()
        qp.begin(self)
        qp.setRenderHints(QtGui.QPainter.Antialiasing, True)
        self.doDrawing(qp)
        qp.end()

    def doDrawing(self, qp):

        blackPen = QtGui.QPen(QtCore.Qt.black, 3, QtCore.Qt.DashLine)
        redPen = QtGui.QPen(QtCore.Qt.red, 30, QtCore.Qt.DashLine)
        bluePen = QtGui.QPen(QtCore.Qt.blue, 3, QtCore.Qt.DashLine)
        greenPen = QtGui.QPen(QtCore.Qt.green, 3, QtCore.Qt.DashLine)
        redBrush = QtGui.QBrush(QtCore.Qt.red)

        steps = 400
        min_t = 0.0
        max_t = 1.0

        dt = (max_t - min_t) / steps
        oldPt = self.controlPts[0]
        pn = 1
        qp.setPen(redPen)
        qp.setBrush(redBrush)
        qp.drawEllipse(oldPt[0] - 3, oldPt[1] - 3, 6, 6)

        for pt in self.controlPts[1:]:
            pn += 1
            qp.setPen(blackPen)

            qp.drawLine(oldPt[0], oldPt[1], pt[0], pt[1])

            qp.setPen(redPen)
            qp.drawEllipse(pt[0] - 3, pt[1] - 3, 6, 6)
            oldPt = pt

        qp.setPen(bluePen)
        oldPt = self.controlPts[0]
        for i in range(steps + 1):
            t = dt * i
            pt = C(t, self.controlPts)
            qp.drawLine(oldPt[0], oldPt[1], pt[0], pt[1])
            oldPt = pt


def main(args):
    app = QtWidgets.QApplication(sys.argv)
    ex = BezierDrawer()
    ex.show()
    app.exec_()


if __name__ == '__main__':
    main(sys.argv[1:])

前提说明:虽然我通常不会用全新的实现来回答此类问题,但 higher-order 贝塞尔曲线的主题非常有趣并且不能直接在 Qt 中使用(并且在任何通用工具包),所以我打破我的一般规则来提供一个扩展的答案,因为我相信它可能对其他人有用。

虽然接受的答案正确地解决了问题,但如前所述,使用 Graphics View Framework 几乎总是更好的选择,因为它提供了更多的模块化、高级交互、优化和功能,这些在一个标准的小部件(特别是缩放和旋转等转换)。

在解释实现之前,关于原始代码的一些重要说明:

  • factorial()函数,它的缓存几乎没有用,因为它可能永远无法使用任何缓存数据,因为数量和多样性结果(有 400 个硬编码步骤,但缓存限制设置为 100); math模块已经提供了factorial(),由于是纯C端实现,速度更快;
  • B()函数只执行一次计算,所以几乎没用; C() 也是如此:虽然创建函数对于可读性和代码分离可能很重要,但在这种情况下,它们的可用性和用法使它们变得毫无意义;
  • 如果你想要曲线的连续虚线,你不能使用 drawLine(),因为它会绘制不同的线段:因为这些线段几乎总是很短,所以永远不会显示虚线;使用 QPainterPath 代替;
  • 如此广泛和重复的计算不应该在paintEvent()(可以非常频繁地调用)中执行,并且最好使用某种级别的缓存;例如,您可以创建一个空的实例属性(例如 self.cachePath = None),检查 paintEvent() 中的属性是否为空,并最终在这种情况下调用创建路径的函数;还要考虑 QPicture;
  • (不相关,但仍然重要)如果您仍然使用完整的 sys.argv;
  • 创建 QApplication,则使用 sys.argv[1:] 调用 main 有点毫无意义

在下面的实现中,我创建了一个主 BezierItem 作为 QGraphicsPathItem which will contain the complete curve and embeds a child QGraphicsPathItem for the line segments and a variable number of ControlPoint objects, which are QGraphicsObject 子类的子类。 QObject 继承是为了添加信号支持,需要通知项目位置变化(技术上可以通过使用基本的 QGraphicsItem 并调用父级的函数来避免,但这不是很优雅)。

请注意,由于曲线可能会调用点计算数千次,因此优化至关重要:考虑一下对于精度仅为 20-step-per-point 的“简单”8 阶曲线,结果是内部计算将执行大约 1200 次。 20个点和40步精度,结果超过16000个循环。

出于优化原因,BezierItem 还包含一个控制点列表,如 QPointFs,它使用两个重要函数:updatePath(),更新连接每个控件的段点,然后调用 _rebuildPath(),它实际上将最终曲线创建为 QPainterPath。区别很重要,因为您可能需要在分辨率更改时重建路径,但点仍然相同。

请注意,您使用了一种特殊的方式来显示控制点(一个笔宽非常粗的小椭圆,由于 Qt 绘制形状的方式导致了一个花哨的圆形八边形)。这可能会导致鼠标检测出现一些问题,因为形状 QGraphicsItems 使用它们的内部路径和笔来构建形状以进行碰撞检测(包括鼠标事件)。为了避免这些问题,我根据该形状创建了一个 simplified() 路径,这将提高性能并确保正确的行为。

import random
from math import factorial
from PyQt5 import QtWidgets, QtGui, QtCore

class ControlPoint(QtWidgets.QGraphicsObject):
    moved = QtCore.pyqtSignal(int, QtCore.QPointF)
    removeRequest = QtCore.pyqtSignal(object)

    brush = QtGui.QBrush(QtCore.Qt.red)

    # create a basic, simplified shape for the class
    _base = QtGui.QPainterPath()
    _base.addEllipse(-3, -3, 6, 6)
    _stroker = QtGui.QPainterPathStroker()
    _stroker.setWidth(30)
    _stroker.setDashPattern(QtCore.Qt.DashLine)
    _shape = _stroker.createStroke(_base).simplified()
    # "cache" the boundingRect for optimization
    _boundingRect = _shape.boundingRect()

    def __init__(self, index, pos, parent):
        super().__init__(parent)
        self.index = index
        self.setPos(pos)
        self.setFlags(
            self.ItemIsSelectable 
            | self.ItemIsMovable
            | self.ItemSendsGeometryChanges
            | self.ItemStacksBehindParent
        )
        self.setZValue(-1)
        self.setToolTip(str(index + 1))
        self.font = QtGui.QFont()
        self.font.setBold(True)

    def setIndex(self, index):
        self.index = index
        self.setToolTip(str(index + 1))
        self.update()

    def shape(self):
        return self._shape

    def boundingRect(self):
        return self._boundingRect

    def itemChange(self, change, value):
        if change == self.ItemPositionHasChanged:
            self.moved.emit(self.index, value)
        elif change == self.ItemSelectedHasChanged and value:
            # stack this item above other siblings when selected
            for other in self.parentItem().childItems():
                if isinstance(other, self.__class__):
                    other.stackBefore(self)
        return super().itemChange(change, value)

    def contextMenuEvent(self, event):
        menu = QtWidgets.QMenu()
        removeAction = menu.addAction(QtGui.QIcon.fromTheme('edit-delete'), 'Delete point')
        if menu.exec(QtGui.QCursor.pos()) == removeAction:
            self.removeRequest.emit(self)

    def paint(self, qp, option, widget=None):
        qp.setBrush(self.brush)
        if not self.isSelected():
            qp.setPen(QtCore.Qt.NoPen)
        qp.drawPath(self._shape)

        qp.setPen(QtCore.Qt.white)
        qp.setFont(self.font)
        r = QtCore.QRectF(self.boundingRect())
        r.setSize(r.size() * 2 / 3)
        qp.drawText(r, QtCore.Qt.AlignCenter, str(self.index + 1))


class BezierItem(QtWidgets.QGraphicsPathItem):
    _precision = .05
    _delayUpdatePath = False
    _ctrlPrototype = ControlPoint
    def __init__(self, points=None):
        super().__init__()
        self.setPen(QtGui.QPen(QtCore.Qt.blue, 3, QtCore.Qt.DashLine))
        self.outlineItem = QtWidgets.QGraphicsPathItem(self)
        self.outlineItem.setFlag(self.ItemStacksBehindParent)
        self.outlineItem.setPen(QtGui.QPen(QtCore.Qt.black, 3, QtCore.Qt.DashLine))

        self.controlItems = []
        self._points = []

        if points is not None:
            self.setPoints(points)

    def setPoints(self, pointList):
        points = []
        for p in pointList:
            if isinstance(p, (QtCore.QPointF, QtCore.QPoint)):
                # always create a copy of each point!
                points.append(QtCore.QPointF(p))
            else:
                points.append(QtCore.QPointF(*p))
        if points == self._points:
            return

        self._points = []
        self.prepareGeometryChange()

        while self.controlItems:
            item = self.controlItems.pop()
            item.setParentItem(None)
            if self.scene():
                self.scene().removeItem(item)
            del item

        self._delayUpdatePath = True
        for i, p in enumerate(points):
            self.insertControlPoint(i, p)
        self._delayUpdatePath = False

        self.updatePath()

    def _createControlPoint(self, index, pos):
        ctrlItem = self._ctrlPrototype(index, pos, self)
        self.controlItems.insert(index, ctrlItem)
        ctrlItem.moved.connect(self._controlPointMoved)
        ctrlItem.removeRequest.connect(self.removeControlPoint)

    def addControlPoint(self, pos):
        self.insertControlPoint(-1, pos)

    def insertControlPoint(self, index, pos):
        if index < 0:
            index = len(self._points)
        for other in self.controlItems[index:]:
            other.index += 1
            other.update()
        self._points.insert(index, pos)
        self._createControlPoint(index, pos)
        if not self._delayUpdatePath:
            self.updatePath()

    def removeControlPoint(self, cp):
        if isinstance(cp, int):
            index = cp
        else:
            index = self.controlItems.index(cp)

        item = self.controlItems.pop(index)
        self.scene().removeItem(item)
        item.setParentItem(None)
        for other in self.controlItems[index:]:
            other.index -= 1
            other.update()

        del item, self._points[index]

        self.updatePath()

    def precision(self):
        return self._precision

    def setPrecision(self, precision):
        precision = max(.001, min(.5, precision))
        if self._precision != precision:
            self._precision = precision
            self._rebuildPath()

    def stepRatio(self):
        return int(1 / self._precision)

    def setStepRatio(self, ratio):
        '''
        Set the *approximate* number of steps per control point. Note that 
        the step count is adjusted to an integer ratio based on the number 
        of control points.
        '''
        self.setPrecision(1 / ratio)
        self.update()

    def updatePath(self):
        outlinePath = QtGui.QPainterPath()
        if self.controlItems:
            outlinePath.moveTo(self._points[0])
            for point in self._points[1:]:
                outlinePath.lineTo(point)
        self.outlineItem.setPath(outlinePath)
        self._rebuildPath()

    def _controlPointMoved(self, index, pos):
        self._points[index] = pos
        self.updatePath()

    def _rebuildPath(self):
        '''
        Actually rebuild the path based on the control points and the selected
        curve precision. The default (0.05, ~20 steps per control point) is
        usually enough, lower values result in higher resolution but slower
        performance, and viceversa.
        '''
        self.curvePath = QtGui.QPainterPath()
        if self._points:
            self.curvePath.moveTo(self._points[0])
            count = len(self._points)
            steps = round(count / self._precision)
            precision = 1 / steps
            n = count - 1
            # we're going to iterate through points *a lot* of times; with the
            # small cost of a tuple, we can cache the inner iterator to speed
            # things up a bit, instead of creating it in each for loop cycle
            pointIterator = tuple(enumerate(self._points))
            for s in range(steps + 1):
                u = precision * s
                x = y = 0
                for i, point in pointIterator:
                    binu = (factorial(n) / (factorial(i) * factorial(n - i)) 
                        * (u ** i) * ((1 - u) ** (n - i)))
                    x += binu * point.x()
                    y += binu * point.y()
                self.curvePath.lineTo(x, y)
        self.setPath(self.curvePath)


class BezierExample(QtWidgets.QWidget):
    def __init__(self):
        super().__init__()
        
        self.bezierScene = QtWidgets.QGraphicsScene()
        self.bezierView = QtWidgets.QGraphicsView(self.bezierScene)
        self.bezierView.setRenderHints(QtGui.QPainter.Antialiasing)
        self.bezierItem = BezierItem([
            (500, 500), (600, 700), (600, 550), (700, 500), 
            (700, 500), (800, 400), (1000, 200), (1000, 500)
        ])
        self.bezierScene.addItem(self.bezierItem)

        mainLayout = QtWidgets.QVBoxLayout(self)
        topLayout = QtWidgets.QHBoxLayout()
        mainLayout.addLayout(topLayout)
        topLayout.addWidget(QtWidgets.QLabel('Resolution:'))

        resSpin = QtWidgets.QSpinBox(minimum=1, maximum=100)
        resSpin.setValue(self.bezierItem.stepRatio())
        topLayout.addWidget(resSpin)

        topLayout.addStretch()
        addButton = QtWidgets.QPushButton('Add point')
        topLayout.addWidget(addButton)

        mainLayout.addWidget(self.bezierView)

        self.bezierView.installEventFilter(self)
        resSpin.valueChanged.connect(self.bezierItem.setStepRatio)
        addButton.clicked.connect(self.addPoint)

    def addPoint(self, point=None):
        if not isinstance(point, (QtCore.QPoint, QtCore.QPointF)):
            point = QtCore.QPointF(
                random.randrange(int(self.bezierScene.sceneRect().width())), 
                random.randrange(int(self.bezierScene.sceneRect().height())))
        self.bezierItem.addControlPoint(point)

    def eventFilter(self, obj, event):
        if event.type() == event.MouseButtonDblClick:
            pos = self.bezierView.mapToScene(event.pos())
            self.addPoint(pos)
            return True
        return super().eventFilter(obj, event)

    def sizeHint(self):
        return QtWidgets.QApplication.primaryScreen().size() * 2 / 3


if __name__ == '__main__':
    import sys
    app = QtWidgets.QApplication(sys.argv)
    ex = BezierExample()
    ex.show()
    app.exec_()

我将来可能会更改上面的代码,主要是为没有控制点交互的 Nth-order 曲线提供有效的后端,并最终提供一个进一步的子类来添加该支持。
但是,现在,所请求的支持已经完成,我 强烈 敦促您仔细花时间研究该代码的每一部分。 Graphics View 框架功能强大,但理解起来却很复杂,可能需要数周(至少!)才能真正掌握它。

一如既往,基本规则仍然存在:研究文档。并且,可能还有源代码。