将鼠标运动功能添加到基于 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
的属性。然后您可以使用事件:mousePressEvent
、mouseMoveEvent
和 mouseReleaseEvent
与控制点进行交互。
首先我们需要找出,如果点击了 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
还包含一个控制点列表,如 QPointF
s,它使用两个重要函数: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 框架功能强大,但理解起来却很复杂,可能需要数周(至少!)才能真正掌握它。
一如既往,基本规则仍然存在:研究文档。并且,可能还有源代码。
我正在尝试修改下面的代码以接受鼠标事件(单击、拖动、释放),以便可以选择和移动控制点,从而改变曲线。我不确定从哪里开始,有什么建议吗?控制点标记为红点,曲线为蓝色。
这基本上可以让我在 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
的属性。然后您可以使用事件:mousePressEvent
、mouseMoveEvent
和 mouseReleaseEvent
与控制点进行交互。
首先我们需要找出,如果点击了 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
还包含一个控制点列表,如 QPointF
s,它使用两个重要函数: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 框架功能强大,但理解起来却很复杂,可能需要数周(至少!)才能真正掌握它。
一如既往,基本规则仍然存在:研究文档。并且,可能还有源代码。