PyQt6 Graphic进阶实战:打造一个视觉恒定的可缩放矩形框

在开发图片标注、视频编辑或CAD类软件时,经常会遇到这样一个需求:在可以缩放的场景中,绘制一个带有控制点的选择框。

听起来很简单?如果你直接继承 QGraphicsRectItem,很快就会遇到两个令人抓狂的"经典BUG":

  1. 点击穿透:明明把控制点画在边角了,鼠标点上去却没反应,事件全被底层的图元吃了。
  2. 视觉变形 :当视图放大好几倍后,原本精致的调整手柄变成了巨大的色块,遮挡了图片细节;或者缩得很小时,手柄完全消失不见。
    今天,我们就来一步步填平这些坑,打造一个交互完美、视觉恒定的 ResizableRectItem

第一关:解决"点击穿透"陷阱

很多初学者会认为:只要我在 paint 里画出来了,并且重写了 boundingRect 把画出来的区域包进去,鼠标就能点到了。
残酷的现实是:boundingRect 只决定了"重绘区域",而决定"鼠标点击区域"的是 shape() 方法。
QGraphicsRectItem 默认的 shape() 只包含 rect() 内部。如果你把控制点画在矩形外面(通常为了选中边框),默认的 shape() 是管不到那里的。

错误的尝试

有人会尝试在 mousePressEvent 里写复杂的坐标判断逻辑,但这无法解决 hover 事件(鼠标悬停变手势)的问题。

正确的解法

我们需要重写 shape(),构建一个包含矩形本体和所有控制点的路径。

python 复制代码
def shape(self):
    # 1. 包含矩形本身
    path = QPainterPath()
    path.addRect(self.rect())
    
    # 2. 包含所有控制点区域
    # 这里的 handle_size 需要动态计算(下文会讲)
    for center in self._get_handle_centers():
        rect = QRectF(
            center.x() - self.handle_size / 2,
            center.y() - self.handle_size / 2,
            self.handle_size,
            self.handle_size,
        )
        path.addRect(rect)
    
    return path

关键点QPainterPath 默认使用 OddEvenFill(奇偶填充),如果两个矩形重叠(比如角上的控制点),重叠区域会被视为"空洞",点击依然会穿透!记得使用 path.setFillRule(Qt.FillRule.WindingFill) 来确保所有区域都是实心的。

第二关:征服"缩放变形"难题

搞定了点击,接下来是视觉体验。我们希望的效果是:无论用户把视图放大还是缩小,边框的线宽和控制点的大小在屏幕上看起来永远是不变的(比如永远是 4px 和 8px)。

原理:反向缩放

假设我们在屏幕上想要一个 8px 的正方形控制点。

  • 当视图放大 2 倍时,1 个 Item 坐标单位对应 2 个屏幕像素。所以我们要画的正方形边长应该是 8/2=48 / 2 = 48/2=4 个 Item 单位。
  • 当视图缩小 0.5 倍时,1 个 Item 单位对应 0.5 个屏幕像素。我们要画的正方形边长应该是 8/0.5=168 / 0.5 = 168/0.5=16 个 Item 单位。
    公式很简单:Item尺寸 = 屏幕期望尺寸 / 当前缩放比例

坑点:如何获取当前缩放比例?

这是最容易写死代码的地方。很多人会写 scene().views()[0].transform().m11()

但问题是:views()[0] 不一定是当前正在操作的视图。 如果你的软件有多个视图(比如主视图和预览小窗),这行代码可能会导致在预览窗里操作时,主视图的控制点大小乱套。
终极方案 是利用 paint 函数的 widget 参数。

python 复制代码
def paint(self, painter, option, widget):
    # widget 是当前正在绘制的 viewport
    # 它的 parent 才是 QGraphicsView
    view = widget.parent()
    if view:
        # m11() 获取水平缩放系数
        self.__cur_scale = view.transform().m11()
    
    # 接下来就可以根据 __cur_scale 计算实际绘制尺寸了
    # ...

第三关:优雅的最终实现

结合以上思路,最终的实现代码非常简洁。我们将复杂的缩放逻辑封装在属性中,让绘制和交互逻辑保持清晰。

完整代码

python 复制代码
from PyQt6.QtWidgets import QGraphicsRectItem, QGraphicsItem, QGraphicsView
from PyQt6.QtCore import Qt, QRectF, QPointF
from PyQt6.QtGui import QPen, QBrush, QColor, QPainter, QTransform, QPainterPath
class ResizableRectItem(QGraphicsRectItem):
    def __init__(
        self,
        rect: QRectF,
        parent=None,
        handle_size=8.0,  # 屏幕坐标宽度
        border_width=4,   # 屏幕坐标宽度
        penColor="#0078D7",
        brushAplha=0.2,
    ):
        super().__init__(rect, parent)
        self.__handle_size = handle_size
        self.__border_width = border_width
        self.penColor = penColor
        self.brushAplha = brushAplha
        self.__cur_scale: float = 1  # 缓存当前缩放比例
        self.setAcceptHoverEvents(True)
        # 交互状态变量
        self._mode = None  # 'Move', 'L', 'R', 'T', 'B', 'TL'...
        self._start_pos = QPointF()
        self._original_rect = QRectF()
    # --- 核心:动态计算 Item 坐标系下的尺寸 ---
    @property
    def handle_size(self):
        # 反向缩放:屏幕尺寸 / 当前缩放比
        return self.__handle_size / self.__cur_scale
    @property
    def border_width(self):
        return self.__border_width / self.__cur_scale
    def boundingRect(self):
        # 必须包含控制点区域,且要随着 handle_size 动态变化
        return self.rect().adjusted(
            -self.handle_size, -self.handle_size, self.handle_size, self.handle_size
        )
    def shape(self):
        # 构建点击区域,包含整个边界框
        # 这种写法最简洁,将控制点区域和主矩形融为一体
        rect = self.boundingRect()
        path = QPainterPath()
        path.addRect(rect)
        return path
    def paint(self, painter, option, widget):
        """
        重绘:核心在于获取缩放比例并应用反向变换
        """
        # 1. 获取当前View的真实缩放比
        view: QGraphicsView = widget.parent()
        if view:
            self.__cur_scale = view.transform().m11()
        # 2. 设置画笔(利用反向计算后的宽度)
        penColor = QColor(self.penColor)
        painter.setPen(QPen(penColor, self.border_width))
        brushColor = QColor(self.penColor)
        brushColor.setAlphaF(self.brushAplha)
        painter.setBrush(brushColor)
        painter.drawRect(self.rect())
        # 3. 绘制控制点
        painter.setBrush(penColor)
        for center in self._get_handle_centers():
            rect = QRectF(
                center.x() - self.handle_size / 2,
                center.y() - self.handle_size / 2,
                self.handle_size,
                self.handle_size,
            )
            painter.drawRect(rect)
    def _get_handle_centers(self):
        """获取8个控制点的中心坐标"""
        r = self.rect()
        cx, cy = r.center().x(), r.center().y()
        return [
            QPointF(r.left(), r.top()), QPointF(cx, r.top()), QPointF(r.right(), r.top()),
            QPointF(r.right(), cy), QPointF(r.right(), r.bottom()), QPointF(cx, r.bottom()),
            QPointF(r.left(), r.bottom()), QPointF(r.left(), cy),
        ]
    def _get_hit_area(self, pos):
        """判断点击位置,返回操作模式"""
        tolerance = self.handle_size * 1.1 # 稍微放宽点击容差
        centers = self._get_handle_centers()
        modes = ["TL", "T", "TR", "R", "BR", "B", "BL", "L"]
        for mode, center in zip(modes, centers):
            if (abs(pos.x() - center.x()) <= tolerance and 
                abs(pos.y() - center.y()) <= tolerance):
                return mode
        if self.rect().contains(pos):
            return "Move"
        return None
    # --- 交互事件 (标准写法,无需关心缩放) ---
    def hoverMoveEvent(self, event):
        mode = self._get_hit_area(event.pos())
        cursors = {
            "TL": Qt.CursorShape.SizeFDiagCursor, "BR": Qt.CursorShape.SizeFDiagCursor,
            "TR": Qt.CursorShape.SizeBDiagCursor, "BL": Qt.CursorShape.SizeBDiagCursor,
            "T": Qt.CursorShape.SizeVerCursor, "B": Qt.CursorShape.SizeVerCursor,
            "L": Qt.CursorShape.SizeHorCursor, "R": Qt.CursorShape.SizeHorCursor,
            "Move": Qt.CursorShape.SizeAllCursor,
        }
        self.setCursor(cursors.get(mode, Qt.CursorShape.ArrowCursor))
        super().hoverMoveEvent(event)
    def mousePressEvent(self, event):
        if event.button() == Qt.MouseButton.LeftButton:
            self._mode = self._get_hit_area(event.pos())
            if self._mode:
                self._start_pos = event.pos()
                self._original_rect = self.rect()
                event.accept()
                return
        super().mousePressEvent(event)
    def mouseMoveEvent(self, event):
        if not self._mode:
            super().mouseMoveEvent(event)
            return
        delta = event.pos() - self._start_pos
        new_rect = QRectF(self._original_rect)
        min_size = 10.0
        if self._mode == "Move":
            new_rect.translate(delta)
            self.setRect(new_rect)
            return
        # 简洁的缩放逻辑
        if "L" in self._mode:
            new_left = self._original_rect.left() + delta.x()
            if new_left < new_rect.right() - min_size: new_rect.setLeft(new_left)
        if "R" in self._mode:
            new_right = self._original_rect.right() + delta.x()
            if new_right > new_rect.left() + min_size: new_rect.setRight(new_right)
        if "T" in self._mode:
            new_top = self._original_rect.top() + delta.y()
            if new_top < new_rect.bottom() - min_size: new_rect.setTop(new_top)
        if "B" in self._mode:
            new_bottom = self._original_rect.bottom() + delta.y()
            if new_bottom > new_rect.top() + min_size: new_rect.setBottom(new_bottom)
        self.setRect(new_rect)
    def mouseReleaseEvent(self, event):
        self._mode = None
        super().mouseReleaseEvent(event)

相关推荐
weixin_408717776 小时前
PHP8.1新特性对AI开发帮助_JIT编译优势【解答】
jvm·数据库·python
Ares-Wang6 小时前
flask》》多线程并发数据安全问题 threading.local werkzeug.local.Local
后端·python·flask
2401_871696526 小时前
golang如何实现Trie前缀树_golang Trie前缀树实现解析
jvm·数据库·python
做个文艺程序员6 小时前
Claude Skill 进阶:多文件结构、脚本集成与触发优化
人工智能·python·开源
覆东流6 小时前
第2天:Python变量与数据类型
开发语言·后端·python
2401_887724506 小时前
Go语言怎么做HTTP连接池_Go语言HTTP连接池教程【基础】
jvm·数据库·python
qq_334563556 小时前
Redis怎样实现Session的分布式共享
jvm·数据库·python
m0_493934536 小时前
CSS如何实现背景图片重复平铺_设置background-repeat为repeat
jvm·数据库·python
2401_897190556 小时前
SQL触发器执行报错如何回滚事务_利用RAISERROR抛出异常
jvm·数据库·python
m0_493934536 小时前
Redis如何批量移动标签_利用SMOVE指令在Set之间转移数据
jvm·数据库·python