2

I have a custom QQuickPaintedItem that draws whatever the user has painted onto it with the mouse. Up until now the implementation has been super simple, just drawing the entire image, even when zoomed in. I've noticed that the FPS is really slow when zoomed in and panning the image around, so I decided to incrementally improve the painting performance.

The current step I'm at is only drawing the subset of the image that is visible. To do this, I'm using this overload of QPainter::drawImage(). Here's the smallest possible example that allows zooming and panning (the important part is 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
            }
        }
    }
}

screenshot

This works well when the zoom level is 1, but as soon as you zoom in, the target and source rects are wrong. I've been trying to fix it but I can't quite wrap my head around it. For example, one naive idea was to do all calculations with non-zoomed coordinates, and then scale the target rect afterwards:

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();

This doesn't work, as the image is increasingly stretched as you e.g. pan downwards with the zoom set to 2, instead of remaining at the same scale.

So, what is the correct way to calculate the target and source rects to ensure that I only draw the visible section of the image when it's zoomed in?

Mitch
  • 23,716
  • 9
  • 83
  • 122

1 Answers1

1

The general idea is to intersect the image rectangle with the paint area rectangle, that is the item rectangle ({0, 0, width(), height()}). Such intersection has to be done in a chosen coordinate system, and the rectangle has to be propagated to the other coordinate system. Let's do the intersection in the target coordinate system:

   // **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()});

Now we transform that rectangle back into the source (image) coordinate system:

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

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

Then one has to choose how to scroll - generally the scroll range is simply anywhere within the source image's rectangle (i.e. mImage.rect(), recalling that it is {0, 0, mImage.width(), mImage.height()}), thus the x/y scroll sliders go between 0 and the width/height of the image, respectively.

The painting could also be implemented by painting the entire image, but unfortunately the paint engine backing the painter doesn't know how to handle clipping - so even if we set clipping right before drawImage, it won't do anything: the painter we have to work with ignores clipping. And thus, at high zoom values, the painting with mRectDraw = false becomes inefficient. This is a deficiency of the paint engine and it definitely could be fixed up in Qt proper.

   // **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);
   }

The remainder of the example follows. The meaning of the zoom spinbox is an exponent on sqrt(2), i.e. value=0 -> zoom=1, value=-2 -> zoom=0.5, `value=4 -> zoom=2', etc. The canvas supports positive non-zero zoom values, i.e. also values below 1.

// https://github.com/KubaO/stackoverflown/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"

And the 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
            }
        }
    }
}
Kuba hasn't forgotten Monica
  • 95,931
  • 16
  • 151
  • 313
  • Thanks a lot for the detailed answer! I'll test it out soon. By the way, the GitHub link gives me a 404. – Mitch Jul 23 '18 at 11:37
  • This answers the question, thanks! Unfortunately I've not yet been able to make it work with my full application, and I have no idea what the difference is... will have to look into it. – Mitch Jul 23 '18 at 15:47