QPainter:仅绘制放大图像的可见区域

QPainter: drawing only the visible area of a zoomed-in image

我有一个自定义的 QQuickPaintedItem,它可以绘制用户用鼠标在其上绘制的任何内容。到目前为止,实现非常简单,只需绘制整个图像,即使在放大时也是如此。我注意到放大和平移图像时 FPS 真的很慢,所以我决定逐步提高绘画性能。

我当前的步骤只是绘制可见的图像子集。为此,我使用 this overload of QPainter::drawImage()。这是允许缩放和平移的最小示例(重要部分是 recalculateStuff()):

main.cpp:

#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QDebug>
#include <QQuickItem>
#include <QImage>
#include <QQuickPaintedItem>
#include <QPainter>
#include <QtMath>

class ImageCanvas : public QQuickPaintedItem
{
    Q_OBJECT
    Q_PROPERTY(QPoint offset READ offset WRITE setOffset NOTIFY offsetChanged)
    Q_PROPERTY(int zoom READ zoom WRITE setZoom NOTIFY zoomChanged)
    Q_PROPERTY(QRect sourceRect READ sourceRect NOTIFY sourceRectChanged)
    Q_PROPERTY(QRect targetRect READ targetRect NOTIFY targetRectChanged)

public:
    ImageCanvas() :
        mZoom(1)
    {
        // Construct a test image from coloured squares.
        mImage = QImage(500, 500, QImage::Format_ARGB32);
        QPainter painter(&mImage);
        for (int y = 0; y < mImage.width(); y += 50) {
            for (int x = 0; x < mImage.width(); x += 50) {
                    const QColor colour((x / 500.0) * 255, (y / 500.0) * 255, 0);
                painter.fillRect(x, y, 50, 50, colour);
            }
        }

        recalculateStuff();
    }

    QPoint offset() const {
        return mOffset;
    }

    void setOffset(const QPoint &offset) {
        mOffset = offset;
        recalculateStuff();
        emit offsetChanged();
    }

    int zoom() const {
        return mZoom;
    }

    void setZoom(int zoom) {
        mZoom = qMax(1, zoom);
        recalculateStuff();
        emit zoomChanged();
    }

    QRect targetRect() const {
        return mTargetRect;
    }

    QRect sourceRect() const {
        return mSourceRect;
    }

    void recalculateStuff() {
        const QRect oldTargetRect = mTargetRect;
        const QRect oldSourceRect = mSourceRect;

        mTargetRect = QRect(0, 0, mImage.width() * mZoom, mImage.height() * mZoom);
        mSourceRect = QRect(0, 0, mImage.width(), mImage.height());

        const int contentLeft = mOffset.x();
        if (contentLeft < 0) {
            // The left edge of the content is outside of the viewport, so don't draw that portion.
            mTargetRect.setX(qAbs(contentLeft));
            mSourceRect.setX(qAbs(contentLeft));
        }

        const int contentTop = mOffset.y();
        if (contentTop < 0) {
            // The top edge of the content is outside of the viewport, so don't draw that portion.
            mTargetRect.setY(qAbs(contentTop));
            mSourceRect.setY(qAbs(contentTop));
        }

        const int contentRight = mOffset.x() + mImage.width();
        const int viewportRight = qFloor(width());
        if (contentRight > viewportRight) {
            // The right edge of the content is outside of the viewport, so don't draw that portion.
            mTargetRect.setWidth(mTargetRect.width() - (contentRight - viewportRight));
            mSourceRect.setWidth(mSourceRect.width() - (contentRight - viewportRight));
        }

        const int contentBottom = mOffset.y() + mImage.height();
        const int viewportBottom = qFloor(height());
        if (contentBottom > viewportBottom) {
            // The bottom edge of the content is outside of the viewport, so don't draw that portion.
            mTargetRect.setHeight(mTargetRect.height() - (contentBottom - viewportBottom));
            mSourceRect.setHeight(mSourceRect.height() - (contentBottom - viewportBottom));
        }

        if (mTargetRect != oldTargetRect)
            emit targetRectChanged();

        if (mSourceRect != oldSourceRect)
            emit sourceRectChanged();

        update();
    }

    void paint(QPainter *painter) override {
        painter->translate(mOffset);
        painter->drawImage(mTargetRect, mImage, mSourceRect);
    }

protected:
    void geometryChanged(const QRectF &, const QRectF &) override {
        recalculateStuff();
    }

signals:
    void offsetChanged();
    void zoomChanged();
    void sourceRectChanged();
    void targetRectChanged();

private:
    QPoint mOffset;
    int mZoom;
    QRect mSourceRect;
    QRect mTargetRect;
    QImage mImage;
};

int main(int argc, char *argv[])
{
    QGuiApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
    QGuiApplication app(argc, argv);

    qmlRegisterType<ImageCanvas>("App", 1, 0, "ImageCanvas");

    QQmlApplicationEngine engine;
    engine.load(QUrl("qrc:/main.qml"));

    return app.exec();
}

#include "main.moc"

main.qml:

import QtQuick 2.10
import QtQuick.Controls 2.3

import App 1.0

ApplicationWindow {
    id: window
    width: 600
    height: 600
    visible: true
    title: "targetRect=" + canvas.targetRect + " sourceRect=" + canvas.sourceRect

    ImageCanvas {
        id: canvas
        anchors.fill: parent
        offset: Qt.point(xOffsetSlider.value, yOffsetSlider.value)
        zoom: zoomSpinBox.value
    }

    SpinBox {
        id: zoomSpinBox
        from: 1
        to: 8
    }

    Slider {
        id: xOffsetSlider
        anchors.bottom: parent.bottom
        width: parent.width - height
        from: -window.width * canvas.zoom
        to: window.width * canvas.zoom

        ToolTip {
            id: xOffsetToolTip
            parent: xOffsetSlider.handle
            visible: true
            text: xOffsetSlider.value.toFixed(1)

            Binding {
                target: xOffsetToolTip
                property: "visible"
                value: !yOffsetToolTip.visible
            }
        }
    }

    Slider {
        id: yOffsetSlider
        anchors.right: parent.right
        height: parent.height - width
        orientation: Qt.Vertical
        from: -window.height * canvas.zoom
        scale: -1
        to: window.height * canvas.zoom

        ToolTip {
            id: yOffsetToolTip
            parent: yOffsetSlider.handle
            text: yOffsetSlider.value.toFixed(1)

            Binding {
                target: yOffsetToolTip
                property: "visible"
                value: !xOffsetToolTip.visible
            }
        }
    }
}

这在缩放级别为 1 时效果很好,但是一旦放大,目标和源矩形就错了。我一直在尝试修复它,但我无法完全理解它。例如,一个天真的想法是用非缩放坐标进行所有计算,然后缩放目标矩形:

diff --git a/main.cpp b/main.cpp
index 8409baf..06841b7 100644
--- a/main.cpp
+++ b/main.cpp
@@ -64,24 +64,24 @@ public:
         const QRect oldTargetRect = mTargetRect;
         const QRect oldSourceRect = mSourceRect;

-        mTargetRect = QRect(0, 0, mImage.width() * mZoom, mImage.height() * mZoom);
+        mTargetRect = QRect(0, 0, mImage.width(), mImage.height());
         mSourceRect = QRect(0, 0, mImage.width(), mImage.height());

-        const int contentLeft = mOffset.x();
+        const int contentLeft = mOffset.x() / mZoom;
         if (contentLeft < 0) {
             // The left edge of the content is outside of the viewport, so don't draw that portion.
             mTargetRect.setX(qAbs(contentLeft));
             mSourceRect.setX(qAbs(contentLeft));
         }

-        const int contentTop = mOffset.y();
+        const int contentTop = mOffset.y() / mZoom;
         if (contentTop < 0) {
             // The top edge of the content is outside of the viewport, so don't draw that portion.
             mTargetRect.setY(qAbs(contentTop));
             mSourceRect.setY(qAbs(contentTop));
         }

-        const int contentRight = mOffset.x() + mImage.width();
+        const int contentRight = (mOffset.x() / mZoom) + mImage.width();
         const int viewportRight = qFloor(width());
         if (contentRight > viewportRight) {
             // The right edge of the content is outside of the viewport, so don't draw that portion.
@@ -89,7 +89,7 @@ public:
             mSourceRect.setWidth(mSourceRect.width() - (contentRight - viewportRight));
         }

-        const int contentBottom = mOffset.y() + mImage.height();
+        const int contentBottom = (mOffset.y() / mZoom) + mImage.height();
         const int viewportBottom = qFloor(height());
         if (contentBottom > viewportBottom) {
             // The bottom edge of the content is outside of the viewport, so don't draw that portion.
@@ -97,6 +97,11 @@ public:
             mSourceRect.setHeight(mSourceRect.height() - (contentBottom - viewportBottom));
         }

+        mTargetRect.setX(mTargetRect.x() * mZoom);
+        mTargetRect.setY(mTargetRect.y() * mZoom);
+        mTargetRect.setWidth(mTargetRect.width() * mZoom);
+        mTargetRect.setHeight(mTargetRect.height() * mZoom);
+
         if (mTargetRect != oldTargetRect)
             emit targetRectChanged();

这是行不通的,因为图像会随着您的移动而逐渐拉伸。将缩放设置为 2 向下平移,而不是保持相同的比例。

那么,计算目标矩形和源矩形的正确方法是什么,以确保在放大图像时只绘制图像的可见部分?

总体思路是将图像矩形与绘画区域矩形相交,即项目矩形({0, 0, width(), height()})。这种相交必须在选定的坐标系中完成,并且矩形必须传播到另一个坐标系。让我们在目标坐标系中做交集:

   // **private
private:
   QImage mImage;
   QPointF mOffset;
   double mZoom = 1.0;
   double mRenderTime = 0.;
   bool mRectDraw = true;
   QRectF mSourceRect;
   QRectF mTargetRect;

   static void moveBy(QRectF &r, const QPointF &o) {
      r = {r.x() + o.x(), r.y() + o.y(), r.width(), r.height()};
   }
   static void scaleBy(QRectF &r, qreal s) {
      r = {r.x() * s, r.y() * s, r.width() * s, r.height() * s};
   }
   void recalculate() {
      const auto oldTargetRect = mTargetRect;
      const auto oldSourceRect = mSourceRect;

      mTargetRect = {{}, mImage.size()};
      moveBy(mTargetRect, -mOffset);
      scaleBy(mTargetRect, mZoom);
      mTargetRect = mTargetRect.intersected({{}, size()});

现在我们将该矩形变换回源(图像)坐标系:

      mSourceRect = mTargetRect;
      scaleBy(mSourceRect, 1.0/mZoom);
      moveBy(mSourceRect, mOffset);

      if (mTargetRect != oldTargetRect)
         emit targetRectChanged(mTargetRect);
      if (mSourceRect != oldSourceRect)
         emit sourceRectChanged(mSourceRect);
      update();
   }

然后必须选择如何滚动 - 通常滚动范围只是源图像矩形内的任意位置(即 mImage.rect(),回想一下它是 {0, 0, mImage.width(), mImage.height()}),因此 x/y 滚动滑块分别在 0 和图像的 width/height 之间移动。

绘画也可以通过绘画整个图像来实现,但不幸的是支持画家的绘画引擎不知道如何处理裁剪 - 所以即使我们在 drawImage 之前设置裁剪,它也不会成功'做任何事情:我们必须与之合作的画家会忽略剪裁。因此,在高缩放值下,mRectDraw = false 的绘画变得低效。这是绘图引擎的缺陷,它肯定可以在 Qt 中修复。

   // **paint
   void paint(QPainter *p) override {
      QElapsedTimer timer;
      timer.start();
      if (mRectDraw) {
         p->drawImage(mTargetRect, mImage, mSourceRect);
      } else {
         p->scale(mZoom, mZoom);
         p->translate(-mOffset);
         p->drawImage(0, 0, mImage);
      }
      mRenderTime = timer.nsecsElapsed() * 1E-9;
      emit renderTimeChanged(mRenderTime);
   }

示例的其余部分如下。 zoom spinbox 的含义是 sqrt(2) 上的指数,即 value=0 -> zoom=1value=-2 -> zoom=0.5、`value=4 -> zoom=2' 等。 canvas 支持正数非零缩放值,即小于 1 的值。

// https://github.com/KubaO/Whosebugn/tree/master/questions/qml-zoom-imagecanvas-51455895
#include <QtQuick>
#include <limits>

class ImageCanvas : public QQuickPaintedItem {
   Q_OBJECT
   Q_PROPERTY(QImage image READ image WRITE setImage NOTIFY imageChanged)
   Q_PROPERTY(QRectF imageRect READ imageRect NOTIFY imageRectChanged)
   Q_PROPERTY(QPointF offset READ offset WRITE setOffset NOTIFY offsetChanged)
   Q_PROPERTY(double zoom READ zoom WRITE setZoom NOTIFY zoomChanged)
   Q_PROPERTY(double renderTime READ renderTime NOTIFY renderTimeChanged)
   Q_PROPERTY(bool rectDraw READ rectDraw WRITE setRectDraw NOTIFY rectDrawChanged)
   Q_PROPERTY(QRectF sourceRect READ sourceRect NOTIFY sourceRectChanged)
   Q_PROPERTY(QRectF targetRect READ targetRect NOTIFY targetRectChanged)
public:
   ImageCanvas(QQuickItem *parent = {}) : QQuickPaintedItem(parent) {}

   QImage image() const { return mImage; }
   QRectF imageRect() const { return mImage.rect(); }
   void setImage(const QImage &image) {
      if (mImage != image) {
         auto const oldRect = mImage.rect();
         mImage = image;
         recalculate();
         emit imageChanged(mImage);
         if (mImage.rect() != oldRect)
            emit imageRectChanged(mImage.rect());
      }
   }
   Q_SIGNAL void imageChanged(const QImage &);
   Q_SIGNAL void imageRectChanged(const QRectF &);

   QPointF offset() const { return mOffset; }
   void setOffset(const QPointF &offset) {
      mOffset = offset;
      recalculate();
      emit offsetChanged(mOffset);
   }
   Q_SIGNAL void offsetChanged(const QPointF &);

   double zoom() const { return mZoom; }
   void setZoom(double zoom) {
      if (zoom != mZoom) {
         mZoom = zoom ? zoom : std::numeric_limits<float>::min();
         recalculate();
         emit zoomChanged(mZoom);
      }
   }
   Q_SIGNAL void zoomChanged(double);

   // **paint
   double renderTime() const { return mRenderTime; }
   Q_SIGNAL void renderTimeChanged(double);

   bool rectDraw() const { return mRectDraw; }
   void setRectDraw(bool r) {
      if (r != mRectDraw) {
         mRectDraw = r;
         recalculate();
         emit rectDrawChanged(mRectDraw);
      }
   }
   Q_SIGNAL void rectDrawChanged(bool);
   QRectF sourceRect() const { return mSourceRect; }
   QRectF targetRect() const { return mTargetRect; }
   Q_SIGNAL void sourceRectChanged(const QRectF &);
   Q_SIGNAL void targetRectChanged(const QRectF &);

protected:
   void geometryChanged(const QRectF &, const QRectF &) override {
      recalculate();
   }

   // **private
};

QImage sampleImage() {
   QImage image(500, 500, QImage::Format_ARGB32_Premultiplied);
   QPainter painter(&image);
   for (int y = 0; y < image.height(); y += 50)
      for (int x = 0; x < image.width(); x += 50) {
         const QColor colour((x / 500.0) * 255, (y / 500.0) * 255, 0);
         painter.fillRect(x, y, 50, 50, colour);
      }
   return image;
}

int main(int argc, char *argv[])
{
   QGuiApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
   QGuiApplication app(argc, argv);

   qmlRegisterType<ImageCanvas>("App", 1, 0, "ImageCanvas");

   QQmlApplicationEngine engine;
   engine.rootContext()->setContextProperty("sampleImage", sampleImage());
   engine.load(QUrl("qrc:/main.qml"));

   return app.exec();
}

#include "main.moc"

和 qml:

import QtQuick 2.10
import QtQuick.Controls 2.3
import App 1.0

ApplicationWindow {
    id: window
    width: 600
    height: 600
    visible: true
    title: "T=" + (canvas.renderTime*1E3).toFixed(1) + "ms t=" + canvas.targetRect + " s=" + canvas.sourceRect

    ImageCanvas {
        id: canvas
        image: sampleImage
        anchors.fill: parent
        offset: Qt.point(xOffsetSlider.value, yOffsetSlider.value)
        zoom: Math.pow(Math.SQRT2, zoomSpinBox.value)
        rectDraw: rectDrawCheckBox.checked
    }

    SpinBox {
        id: zoomSpinBox
        anchors.bottom: xOffsetSlider.top
        from: -10
        to: 20
    }

    CheckBox {
        id: rectDrawCheckBox
        anchors.left: zoomSpinBox.right
        anchors.bottom: xOffsetSlider.top
        text: "rectDraw"
        checked: true
    }

    Slider {
        id: xOffsetSlider
        anchors.bottom: parent.bottom
        width: parent.width - height
        from: 0
        to: canvas.imageRect.width

        ToolTip {
            id: xOffsetToolTip
            parent: xOffsetSlider.handle
            visible: true
            text: xOffsetSlider.value.toFixed(1)

            Binding {
                target: xOffsetToolTip
                property: "visible"
                value: !yOffsetToolTip.visible
            }
        }
    }

    Slider {
        id: yOffsetSlider
        anchors.right: parent.right
        height: parent.height - width
        orientation: Qt.Vertical
        from: canvas.imageRect.height
        to: 0

        ToolTip {
            id: yOffsetToolTip
            parent: yOffsetSlider.handle
            text: yOffsetSlider.value.toFixed(1)

            Binding {
                target: yOffsetToolTip
                property: "visible"
                value: !xOffsetToolTip.visible
            }
        }
    }
}