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)

相关推荐
luanma1509802 小时前
PHP vs C#:30字秒懂两大语言核心差异
android·开发语言·python·php·laravel
Channing Lewis2 小时前
Python 全局变量调用了一个函数,如何实现每次使用时都运行一次函数获取最新的结果
开发语言·python
浅墨cgz2 小时前
查找并删除源目录中与目标目录重复的文件
python
云姜.2 小时前
YAML简单使用
python
喵手2 小时前
Python爬虫实战:手把手教你Python 自动化构建志愿服务岗位结构化数据库!
爬虫·python·自动化·数据采集·爬虫实战·零基础python爬虫教学·志愿服务岗位结构数据库打造
chushiyunen2 小时前
python numpy包的使用
开发语言·python·numpy
小邓睡不饱耶2 小时前
Python多线程爬虫实战:爬取论坛帖子及评论
开发语言·爬虫·python
喵手3 小时前
Python爬虫实战:手把手教你如何采集开源字体仓库目录页(Google Fonts / 其他公开字体目录)!
爬虫·python·自动化·数据采集·爬虫实战·零基础python爬虫教学·开源字体仓库目录页采集
Chase_______3 小时前
【Python 基础】第2章:流程控制完全指南(if/match/while/for)
python