はじめに
Qt Advent Calendar 2023 も三日目がやってまいりました・・・といいながら、間に合っていませんでした。ごめんなさい。
QPainterでの画像変形
QPainterは描画に際して、元のデータを変更せずに描画時に移動・回転・スケーリング(拡大・縮小)・剪断といった変形を行う機能があります。これは、先日の論理的な座標をデバイス座標へのレンダリング時に座標変換する機能です。
例題のカスタマイズ
まず、わかりやすくするため先日の例題をカスタマイズしましょう。
#include <QtGui>
class Window : public QRasterWindow
{
Q_OBJECT
public:
Window() : QRasterWindow()
{
resize(800,600); // 1
}
void paintEvent(QPaintEvent* event)
{
Q_UNUSED(event);
QPainter painter(this);
auto font = qApp->font();
font.setPixelSize(14);
painter.setFont(font);
painter.setBackground(Qt::white);
painter.eraseRect(rect());
QRect r1(0,0,160,160); // 2
QRect r2(r1.bottomRight(), QSize(80,80)); // 3
painter.drawRect(r1);
painter.drawEllipse(r1);
painter.drawText(r1, Qt::AlignCenter, "2023");
painter.setPen(Qt::red);
painter.drawRect(r2); // 4
}
QRect rect() const { return QRect(0,0,width(),height()); }
};
int main(int argc, char* argv[])
{
QGuiApplication app(argc, argv);
Window window;
window.show();
return app.exec();
}
#include "main.moc"
- ウィンドウサイズを800x600へ拡大
- 描画するオブジェクトのサイズは前回同様 160x160で指定
- ついでにもう一つ80x80の矩形r2を追加
- r2は赤色で描画
するとこんな感じになります。
移動
まず今回描画したオブジェクトをウィンドウの中心に描画したいと思います。
r1とr2の開始位置をうまく移動してあげれば良いわけですが、これをQPainterでセンター表示されるようレンダリング時に移動する設定をしてみましょう。
void paintEvent(QPaintEvent* event)
{
Q_UNUSED(event);
QPainter painter(this);
auto font = qApp->font();
font.setPixelSize(14);
painter.setFont(font);
painter.setBackground(Qt::white);
painter.eraseRect(rect());
QRect r1(0,0,160,160);
QRect r2(r1.bottomRight(), QSize(80,80));
QRect r3 = r1.united(r2); // 1
painter.translate(rect().center()-r3.center()); // 2
painter.drawRect(r1);
painter.drawEllipse(r1);
painter.drawText(r1, Qt::AlignCenter, "2023");
painter.setPen(Qt::red);
painter.drawRect(r2);
painter.setPen(Qt::blue);
painter.drawRect(r1.united(r3)); // 3
}
- r1とr2境界を含む境界長方形 r3を作り
- ウィンドウの中心とr3の中心の差をQPainter::translateに渡します
- わかりやすくするようr3を青枠で表示します
QRectで計算しているので単純なcenterの差ですと微妙にずれますが気になる場合は精度を上げるためにQRectFを使うなど工夫が必要です。
回転
回転はQPainter::rotate()を利用します。
painter.translate(rect().center()-r3.center());
painter.rotate(45); // 追加
rotateはZ軸を基準に回転しています。
拡大・縮小
スケールを変更するのはQPainter::scale()を利用します。
painter.rotate(45);
painter.scale(1.5, 1.5); // 追加
上記では、X軸、Y軸方向とも1.5倍に拡大しています。
描画に対しての変換のため、線の太さも太くなっていることがわかるかと思います。
剪断
剪断は、矩形をスライドさせて変形する機能です。これにはQPainter::shear() を利用します。
painter.scale(1.5, 1.5);
painter.shear(-0.3, -0.3); // 追加
行列・おぼえていますか
今、あなたの声が聴こえる。「覚えていません」と・・・いや、突然すいませんでした。年寄りのアニオタには通じるのですが若者にはさっぱりなネタで失礼しました。
ここまで、画像の変形をする機能をみてきましたが、これらの変形はQPainterが所有するQTransform型の3x3行列であるWorldTransformを使って実現されています。
QRectだと座標変換と言われてもちょっとイメージしにくかったりしますが、2Dグラフィックスでは図形は(x,y)の2値で表される座標とそれを持ってどう表現するのかの指定で実現されます。例えば、QRect(0,0 160x160)をdrawRect()するということは、以下の4点の座標を線で結んでいるとも言えます。
- (0,0)
- (160,0)
- (160,160)
- (0, 160)
ちょっとdrawRectを別の方法を使って描画してみましょう。
void paintEvent(QPaintEvent* event)
{
QPainter painter(this);
auto font = qApp->font();
font.setPixelSize(14);
painter.setFont(font);
painter.setBackground(Qt::white);
painter.eraseRect(rect());
QPolygon ra{{0,0},{160,0},{160,160},{0,160}};
painter.drawPolygon(ra);
}
drawPolygonは与えられた点を順に線で結び、最後は始点に向けて線を引きます。つまり、以下の結果となります。
これを中心に移動するとなると、x軸方向に320, Y軸方向に220移動することになります。
QPolygon ra2{{0+320,0+220},{160+320,0+220},
{160+320,160+220},{0+320,160+220}};
painter.setPen(Qt::red);
painter.drawPolygon(ra2);
行列を使うと複数の座標変換を効率的に、正確に、簡単に表すことが可能になります。
3x3の行列で、Qtの公式ドキュメント通りの並びで表記するなら以下のような形になります。
\begin{Bmatrix}
m11 & m12 & m13 \\
m21 & m22 & m23 \\
m31 & m32 & m33 \\
\end{Bmatrix}
(x,y)座標をこの行列で変換する場合の計算式は以下のようになっています。
Nx = m11 * x + m21 * y + m31;
Ny = m22 * y + m12 * x + m32;
if (isAffine()) {
Nw = m13 * x + m23 * y + m33;
Nx /= Nw;
Ny /= Nw;
}
つまり以下の行列の場合は、元の座標そのままとなります。
\begin{Bmatrix}
1 & 0 & 0 \\
0 & 1 & 0 \\
0 & 0 & 1 \\
\end{Bmatrix}
Nx = x;
Ny = y;
if (isAffine()) {
Nw = 1;
Nx /= Nw;
Ny /= Nw;
}
で、(320,220)並行移動させたい場合は上記行列を使うなら
\begin{Bmatrix}
1 & 0 & 0 \\
0 & 1 & 0 \\
320 & 220 & 1 \\
\end{Bmatrix}
Nx = x + 320;
Ny = y + 220;
if (isAffine()) {
Nw = 1;
Nx /= Nw;
Ny /= Nw;
}
となります。実際、QTransformを使った座標変換コードを追加すると
auto ra3 = QTransform(1,0,0,0,1,0,320,220,1).map(ra);
painter.setPen(Qt::green);
painter.drawPolygon(ra3);
ra2とra3は同じ座標になるため、緑線で上書きされます。
ついでなので、ここまでみてきた変換を同様にQTransformの3x3で表すと以下のようになります。
移動
QPainter::translate(dx,dy);
\begin{Bmatrix}
1 & 0 & 0 \\
0 & 1 & 0 \\
dx & dy & 1 \\
\end{Bmatrix}
回転
QPainter::translate(a);
\begin{Bmatrix}
cos(a) & sin(a) & 0 \\
-sin(a) & cos(a) & 0 \\
0 & 0 & 1 \\
\end{Bmatrix}
スケーリング
QPainter::scale(sx, sy);
\begin{Bmatrix}
sx & 0 & 0 \\
0 & sy & 0 \\
0 & 0 & 1 \\
\end{Bmatrix}
剪断
QPainter::shear(sh, sv);
\begin{Bmatrix}
1 & sv & 0 \\
sh & 1 & 0 \\
0 & 0 & 1 \\
\end{Bmatrix}
QTransformを使おう
QPainterの画像変形は、QTransformを利用していると説明しましたが、実際のQtでの処理を見てみましょう。
void QPainter::scale(qreal sx, qreal sy)
{
:
:
d->state->worldMatrix.scale(sx,sy);
d->state->WxF = true;
d->updateMatrix();
}
void QPainter::shear(qreal sh, qreal sv)
{
:
:
d->state->worldMatrix.shear(sh, sv);
d->state->WxF = true;
d->updateMatrix();
}
worldMatrixの処理を読んでいますが、その後にupdateMatrixを実施しています。
void QPainterPrivate::updateMatrix()
{
state->matrix = state->WxF ? state->worldMatrix : QTransform();
if (state->VxF)
state->matrix *= viewTransform();
txinv = false; // no inverted matrix
state->matrix *= state->redirectionMatrix;
if (extended)
extended->transformChanged();
else
state->dirtyFlags |= QPaintEngine::DirtyTransform;
state->matrix *= hidpiScaleTransform();
QPainterは、指定された画像変換を行うためのWorldTransformの他に、この後説明するWindow/Viewportの変換を行うviewTransformやredirectionMatrix, hidpiScaleTransformなど複数の行列を有しており、最終的に描画に利用する行列をその都度計算しています。
わずかでも描画速度を速くしたい場合など些細なことですが先にQTransformを作ってから受け渡してしまう方が多少早くなる可能性があります。
#if 0
painter.translate(rect().center()-r3.center());
painter.rotate(45);
painter.scale(1.5,1.5);
painter.shear(-0.3,-0.3);
#else
QTransform matrix;
auto offset = rect().center()-r3.center();
matrix.translate(offset.x(), offset.y());
matrix.rotate(45).scale(1.5,1.5);
matrix.shear(-0.3,-0.3);
painter.setWorldTransform(matrix);
#endif
なお、QTransformは2D画像処理用に実装されており、QPainterを経由しなくても直接以下の図形座標変換にも利用できます。
- QPoint/QPointF (点)
- QLine/QLineF (線)
- QRect/QRectF (矩形)
- QPolygon/QPolygonF (多角形)
- QRegion (領域)
- QPainterPath(ベクターグラフィックス)
QPainterは描画全体に対して反映されるため、scaleを変えたりすると当然ペン幅も同様に拡大されます。また、上記のコードをみてもらってわかる通り、QPainter経由で変形をこまめに変更しながら図形を描画していくのには向いていません。
そのような場合は、先に個別にQTransformで図形を変形してからQPainterで描画する方が効率的です。また、拡大や回転などをこまめに自分で計算式を書くと非常に複雑なコードになります。QTransformを使うとコードがシンプルになるので覚えておくと良いでしょう。
WindowとViewport
1日目の記事ではQPainterで描画する際には二種類の座標系があると言うことを説明しました。描画対象側のデバイス座標系と、QPainter側の論理座標系です。
この座標系について、論理座標系を変更するのがWindow、デバイス座標系を変更するのがViewportとなります。まぁ、言葉で説明してもわかりにくいのでもう一度例題にもどりましょう。
#include <QtGui>
class Window : public QRasterWindow
{
Q_OBJECT
public:
Window() : QRasterWindow()
{
resize(600,600);
}
void paintEvent(QPaintEvent* event)
{
Q_UNUSED(event);
QPainter painter(this);
painter.setBackground(Qt::white);
painter.eraseRect(rect());
auto font = qApp->font();
font.setPixelSize(14);
painter.setFont(font);
painter.drawRect(rect());
painter.drawEllipse(rect());
painter.drawText(rect(), Qt::AlignCenter, "2023");
}
QRect rect() const { return QRect(0,0,width(),height()); }
};
int main(int argc, char* argv[])
{
QGuiApplication app(argc, argv);
Window window;
window.show();
return app.exec();
}
#include "main.moc"
最初の例題を少し変更したものです。サイズを変えてマージンをとっぱらっています。
Viewport
このウィンドウの矩形は600x600を指定しています。しかしウィンドウいっぱいに描画されてしまうと枠が見えないので、デバイスのviewportにマージンをとって設定してみましょう。
void paintEvent(QPaintEvent* event)
{
Q_UNUSED(event);
QPainter painter(this);
painter.setBackground(Qt::white);
painter.eraseRect(rect());
painter.setViewport(rect()-QMargins(5,5,6,6));
auto font = qApp->font();
font.setPixelSize(14);
painter.setFont(font);
painter.drawRect(rect());
painter.drawEllipse(rect());
painter.drawText(rect(), Qt::AlignCenter, "2023");
}
rect()が返す値はQRect(0,0 600x600)です。viewportとして設定された矩形は(5,5 589x589)になります。
論理座標系はQRect(0,0 600x600)がデバイス座標系QRect(5,5 589x589)となるように内部的に変換されることになります。
Window
今度は論理座標系を変換するWindowを設定してみましょう。中心が(0,0)になるように、(-300,-300 600x600)にでもしてみましょうか。
void paintEvent(QPaintEvent* event)
{
Q_UNUSED(event);
QPainter painter(this);
painter.setBackground(Qt::white);
painter.eraseRect(rect());
painter.setWindow(QRect(QPoint(-300,-300),QSize(600,600)));
auto font = qApp->font();
font.setPixelSize(14);
painter.setFont(font);
painter.drawRect(rect());
painter.drawEllipse(rect());
painter.drawText(rect(), Qt::AlignCenter, "2023");
}
今度は、論理座標のQRect(-300,-300 600x600)がデバイスQRect(0,0 600x600)になるように設定されています。このため、QRect(0,0 600x600)のdrawRect()はウィンドウのほぼ中心から描画が始まっていることが見て取れます。
Qtのソースコードを抜粋しておきます。
void QPainter::setViewport(const QRect &r)
{
:
:
d->state->vx = r.x();
d->state->vy = r.y();
d->state->vw = r.width();
d->state->vh = r.height();
d->state->VxF = true;
d->updateMatrix();
}
void QPainter::setWindow(const QRect &r)
{
:
:
d->state->wx = r.x();
d->state->wy = r.y();
d->state->ww = r.width();
d->state->wh = r.height();
d->state->VxF = true;
d->updateMatrix();
}
QTransform QPainterPrivate::viewTransform() const
{
if (state->VxF) {
qreal scaleW = qreal(state->vw)/qreal(state->ww);
qreal scaleH = qreal(state->vh)/qreal(state->wh);
return QTransform(scaleW, 0, 0, scaleH,
state->vx - state->wx*scaleW, state->vy - state->wy*scaleH);
}
return QTransform();
}
# まとめ
今回の記事では、QPainterで行われる座標系の変換に関してとりあえげました。
4日目の記事は、@Atsushi4 さんによる勉強会のご紹介となります。