正在加载中...

如何在pyqt中通过OpenCV实现对窗口的透视变换

窗口的透视变换效果

当我们点击UWP应用中的小部件时,会发现小部件会朝着鼠标点击位置凹陷下去,而且不同的点击位置对应着不同的凹陷情况,看起来就好像小部件在屏幕上不只有x轴和y轴,甚至还有一个z轴。要做到这一点,其实只要对窗口进行透视变换即可。下面是对Qt的窗口和按钮进行透视变换的效果(GitHub 仓库:Groove):

具体代码

PixmapPerspectiveTransform 类

它的作用是将传入的 QPixmap 转换为numpy 数组,然后用 opencvwarpPerspective 对数组进行透视变换,最后再将 numpy 数组转为 QPixmap 并返回;

class PixmapPerspectiveTransform:
    """ Pixmap perspective transform class """

    def __init__(self, pixmap=None):
        self.pixmap = pixmap

    def setPixmap(self, pixmap: QPixmap):
        """ set the image to be transformed """
        self.pixmap = QPixmap
        self.src = self.transQPixmapToNdarray(pixmap)
        self.height, self.width = self.src.shape[:2]

        # corner coordinates before transformation
        self.srcPoints = np.float32(
            [[0, 0], [self.width - 1, 0], [0, self.height - 1],
             [self.width - 1, self.height - 1]])

    def setDstPoints(self, leftTop: list, rightTop, leftBottom, rightBottom):
        """ set the corner coordinates after transformation """
        self.dstPoints = np.float32(
            [leftTop, rightTop, leftBottom, rightBottom])

    def getPerspectiveTransform(self, imWidth: int, imHeight: int, borderMode=cv.BORDER_CONSTANT, borderValue=[255, 255, 255, 0]) -> QPixmap:
        """ get transformed image

        Parameters
        ----------
        imWidth: int
            image width before transformation

        imHeight: int
            image height before transformation

        borderMode: int
            border interpolation mode

        borderValue: list
            filled border color

        Returns
        -------
        pixmap: QPixmap
            image after transformation
        """
        # handle jpeg image
        if self.src.shape[-1] == 3:
            self.src = cv.cvtColor(self.src, cv.COLOR_BGR2BGRA)

        # calculate transform matrix
        perspectiveMatrix = cv.getPerspectiveTransform(
            self.srcPoints, self.dstPoints)

        # apply perspective transform
        self.dst = cv.warpPerspective(self.src, perspectiveMatrix, (
            imWidth, imHeight), borderMode=borderMode, borderValue=borderValue)

        return self.transNdarrayToQPixmap(self.dst)

    def transQPixmapToNdarray(self, pixmap: QPixmap):
        """ 将QPixmap转换为numpy数组 """
        width, height = pixmap.width(), pixmap.height()
        channels_count = 4
        image = pixmap.toImage()  # type:QImage
        s = image.bits().asstring(height * width * channels_count)

        # BGRA image array
        array = np.fromstring(s, np.uint8).reshape(
            (height, width, channels_count))

        return array

    def transNdarrayToQPixmap(self, array: np.ndarray):
        """ convert numpy array to QPixmap """
        height, width, bytesPerComponent = array.shape
        bytesPerLine = 4 * width

        # array shape: m*n*4
        dst = cv.cvtColor(array, cv.COLOR_BGRA2RGBA)
        pix = QPixmap.fromImage(
            QImage(dst.data, width, height, bytesPerLine, QImage.Format_RGBA8888))
        return pix

PerspectiveWidget 类

当我们的鼠标单击这个类实例化出来的窗口时,窗口会先通过 self.grab() 被渲染为QPixmap,然后调用 PixmapPerspectiveTransform 中的方法对QPixmap进行透视变换,拿到透视变换的结果后只需隐藏窗口内的小部件并通过 PaintEvent 将结果绘制到窗口上即可。虽然思路很通顺,但是实际操作起来会发现对于透明背景的窗口进行透视变换时,与透明部分交界的部分会被插值上半透明的像素。对于本来就属于深色的像素来说这没什么,但是如果像素是浅色的就会带来很大的视觉干扰,你会发现这些浅色部分旁边被描上了一圈黑边,我们先将这个图像记为img_1img_1 差不多长这个样子,可以很明显看出白色的文字围绕着一圈黑色的描边。

为了解决这个烦人的问题,我又对桌面上的窗口进行截屏,再次透视变换。注意是桌面上看到的窗口,这时的窗口肯定是会有背景的,这时的透视变换就不会存在上述问题,记这个透视变换完的图像为 img_2。但实际上我们本来是不想要 img_2 中的背景的,所以只要将 img_2 中的背景替换完img_1中的透明背景,下面是具体代码:

# coding:utf-8
from common.get_pressed_pos import getPressedPos
from common.image_process_utils import PixmapPerspectiveTransform
from PyQt5.QtCore import QPoint, Qt
from PyQt5.QtGui import QPainter, QPixmap, QScreen
from PyQt5.QtWidgets import QApplication, QWidget


class PerspectiveWidget(QWidget):
    """ A widget which can apply perspective transform when clicked """

    def __init__(self, parent=None, isTransScreenshot=False):
        super().__init__(parent)
        self.__visibleChildren = []
        self.__isTransScreenshot = isTransScreenshot
        self.__perspectiveTrans = PixmapPerspectiveTransform()
        self.__screenshotPix = None
        self.__pressedPix = None
        self.__pressedPos = None

    @property
    def pressedPos(self) -> str:
        return self.__pressedPos

    def mousePressEvent(self, e):
        super().mousePressEvent(e)
        if self.__pressedPos:
            return

        # grab screen
        self.grabMouse()
        pixmap = self.grab()
        self.__perspectiveTrans.setPixmap(pixmap)

        # get destination corner coordinates after transform
        self.__setDstPointsByPressedPos(getPressedPos(self, e))

        self.__pressedPix = self.__getTransformPixmap()

        if self.__isTransScreenshot:
            self.__adjustTransformPix()

        # 隐藏本来看得见的小部件
        self.__visibleChildren = [
            i for i in self.children() if hasattr(i, "isVisible") and i.isVisible()]

        for child in self.__visibleChildren:
            if hasattr(child, "hide"):
                child.hide()

        self.update()

    def mouseReleaseEvent(self, e):
        super().mouseReleaseEvent(e)
        self.releaseMouse()
        self.__pressedPos = None
        self.update()

        for child in self.__visibleChildren:
            if hasattr(child, "show"):
                child.show()

    def paintEvent(self, e):
        """ paint widget """
        super().paintEvent(e)
        painter = QPainter(self)
        painter.setRenderHints(
            QPainter.Antialiasing
            | QPainter.HighQualityAntialiasing
            | QPainter.SmoothPixmapTransform
        )
        painter.setPen(Qt.NoPen)

        # paint perspective transformed image
        if self.__pressedPos:
            painter.drawPixmap(self.rect(), self.__pressedPix)

    def __setDstPointsByPressedPos(self, pressedPos: str):
        """ get destination corner coordinates after transform """
        self.__pressedPos = pressedPos
        w = self.__perspectiveTrans.width
        h = self.__perspectiveTrans.height
        dstPointMap = {
            "left": [[5, 4], [w - 2, 1], [3, h - 3], [w - 2, h - 1]],
            "left-top": [[7, 6], [w - 1, 1], [1, h - 2], [w - 2, h - 1]],
            "left-bottom": [[0, 1], [w - 3, 0], [6, h - 5], [w - 2, h - 2]],
            "center": [[3, 4], [w - 4, 4], [3, h - 3], [w - 4, h - 3]],
            "top": [[4, 5], [w - 5, 5], [0, h - 1], [w - 1, h - 1]],
            "bottom": [[0, 0], [w - 1, 0], [4, h - 4], [w - 5, h - 4]],
            "right-bottom": [[1, 0], [w - 3, 2], [1, h - 2], [w - 6, h - 5]],
            "right-top": [[0, 1], [w - 7, 5], [2, h - 1], [w - 2, h - 2]],
            "right": [[1, 1], [w - 6, 4], [2, h - 1], [w - 4, h - 3]]
        }
        self.__perspectiveTrans.setDstPoints(*dstPointMap[pressedPos])

    def __getTransformPixmap(self) -> QPixmap:
        """ get the image of window after transformed """
        pix = self.__perspectiveTrans.getPerspectiveTransform(
            self.__perspectiveTrans.width, self.__perspectiveTrans.height
        ).scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
        return pix

    def __getScreenShot(self) -> QPixmap:
        """ get screen shot """
        screen = QApplication.primaryScreen()  # type:QScreen
        pos = self.mapToGlobal(QPoint(0, 0))  # type:QPoint
        pix = screen.grabWindow(0, pos.x(), pos.y(),
                                self.width(), self.height())
        return pix

    def __adjustTransformPix(self):
        """ adjust transform pixmap to elimate black edge """
        self.__screenshotPix = self.__getScreenShot()
        self.__perspectiveTrans.setPixmap(self.__screenshotPix)
        self.__screenshotPressedPix = self.__getTransformPixmap()

        img_1 = self.__perspectiveTrans.transQPixmapToNdarray(
            self.__pressedPix)
        img_2 = self.__perspectiveTrans.transQPixmapToNdarray(
            self.__screenshotPressedPix)

        mask = img_1[:, :, -1] == 0
        img_2[mask] = img_1[mask]
        self.__pressedPix = self.__perspectiveTrans.transNdarrayToQPixmap(
            img_2)

mousePressEvent中调用了一个全局函数 getPressedPos(widget,e) ,如果将窗口分为九宫格,它就是用来获取判断鼠标的点击位置落在九宫格的哪个格子的,因为我在其他地方有用到它,所以没将其设置为PerspectiveWidget的方法成员。下面是这个函数的代码:

# coding:utf-8

from PyQt5.QtGui import QMouseEvent


def getPressedPos(widget, e: QMouseEvent) -> str:
    """ 检测鼠标并返回按下的方位 """
    pressedPos = None

    width = widget.width()
    height = widget.height()
    leftX = 0 <= e.x() <= int(width / 3)
    midX = int(width / 3) < e.x() <= int(width * 2 / 3)
    rightX = int(width * 2 / 3) < e.x() <= width
    topY = 0 <= e.y() <= int(height / 3)
    midY = int(height / 3) < e.y() <= int(height * 2 / 3)
    bottomY = int(height * 2 / 3) < e.y() <= height

    # 获取点击位置
    if leftX and topY:
        pressedPos = 'left-top'
    elif midX and topY:
        pressedPos = 'top'
    elif rightX and topY:
        pressedPos = 'right-top'
    elif leftX and midY:
        pressedPos = 'left'
    elif midX and midY:
        pressedPos = 'center'
    elif rightX and midY:
        pressedPos = 'right'
    elif leftX and bottomY:
        pressedPos = 'left-bottom'
    elif midX and bottomY:
        pressedPos = 'bottom'
    elif rightX and bottomY:
        pressedPos = 'right-bottom'

    return pressedPos

使用方法

很简单,只要将代码中的 QWidget 替换为 PerspectiveWidget。要对按钮也进行透视变换,只要按代码中所做的那样重写mousePressEventmouseReleaseEventpaintEvent 即可,如果有对按钮使用qss,记得在 paintEvent 中加上super().paintEvent(e),这样样式表才会起作用。总之框架已经给出,具体操作取决于你。如果你喜欢这篇博客的话,记得点个赞哦~~

posted @ 2021-04-11 16:21  之一Yo  阅读(452)  评论(0编辑  收藏  举报