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)

相关推荐
笨鸟先飞,勤能补拙1 小时前
AI 赋能网络安全:技术全景、成熟度评估与实战案例
人工智能·python·安全·web安全·网络安全·sqlite·github
长和信泰光伏储能2 小时前
京津冀光伏发电:绿色能源的未来之路
python·能源
浦信仿真大讲堂2 小时前
从重复操作到自动化闭环:如何让 CST 与 Python 真正协同起来
python·自动化·cst·仿真软件·达索软件
Gu Gu Study3 小时前
ScoutLoop开放域深度研究引擎(agent的初步设计想法)
人工智能·python
卷无止境4 小时前
写代码这件事,到底该讲究点什么?
后端·python
卷无止境4 小时前
循环复杂度到底在算什么,Python 代码怎么才能写得让人一看就懂
后端·python
lpfasd1234 小时前
MediaCrawler 项目深度分析
chrome·python·chrome devtools
Dxy12393102164 小时前
Python项目打包成EXE完整教程(PyInstaller实战避坑)
开发语言·python
bamb005 小时前
一个项目带你入门AI应用开发01
python
0566465 小时前
Python康复训练——常用标准库
开发语言·python·学习