Thor's Columns
PyQtでお手軽GUI開発♪―――は可能だったか? 第4回 画面描画編

PyQtでお手軽GUI開発♪―――は可能だったか? 第4回 画面描画編


 というわけで自作の関数を必要に応じて起動できるようになったわけですが、GUIアプリを作る以上はまずやってみたいこととは、画面に何かを描くことでしょう。

 そこで今回は画面描画の方法について解説してみます。

 ただ、Qtというのは膨大な規模のライブラリで、画面描画だけでも詳しく書いていれば一冊の本になってしまいそうな分量があります。もちろんここでそんなことをする余裕も意味もないので、細かいことはドキュメントを見てもらうということで、一番基本となる部分と、やってみて分かりにくかった点を中心に解説していきます。


PaintEventとQPainter

 Qtで画面上に何かを描画する場合には、QPainterというものを使います。

 以下はそれを使った単純なサンプルです。

def paintEvent(self, event):

    canvas = Qg.QPainter(self)                          #MainWindowのQPainterを取得

    pen = Qg.QPen(Qt.red)                               #赤色のペンを作り
    pen.setWidth(3)                                     #幅を3に設定して
    canvas.setPen(pen)                                  #それをPainterに設定
    canvas.setBrush(Qt.green)                           #緑のブラシを設定(単に色だけならこれでOK)
    canvas.drawRect(20, 20, 100, 100)                   #(20,20)の位置にサイズ100の正方形を描く

    canvas.setPen(Qt.blue)                              #ペンの色を青に設定(色だけなら同様にこれでOK)
    font = Qg.QFont()                                   #QFontを作成
    font.setPointSize(20)                               #文字のサイズを指定
    canvas.setFont(font)                                #フォントをPainterに設定
    canvas.drawText(150,50, "今日は朝から夜だった!")   #(150,50)の位置に文字を描画

 上記のコードは別な環境でGUIを弄ったことがある人ならばやっていることはほぼ一目瞭然でしょう。

 まずpaintEventという関数ですが、前回やったとおりこれはQWidgetのプロテクトメソッドで、画面描画の必要が発生したとき(=Paintメッセージが来たとき)自動的に呼ばれます。

 続いて関数の最初の行で、canvasという名前でselfを引数にQpainterというクラスのインスタンスを作っています。

 次にそのcanvasに幅3の赤色ペンと緑色のブラシを設定し、drawRectという関数を呼んでいます。

 またその右の方にペンを青に設定してフォントサイズを20ポイントにしてdrawTextという関数を呼んでいます。

 これを実行するとご想像の通り以下のようになります。




 まとめてみるとQtで画面描画を行うには

  1. ウインドウや画像のQPainterを取得する
  2. QPen(線)やQBursh(塗りつぶしパターン)それにQFontなどをQPainterに設定して
  3. 描画関数を呼び出す

という基本手順になります。

 こう書けばもう大体雰囲気は分かるのではないでしょうか。


 このQPainterはQWidgetを継承しているクラス(要するにWidgetなら何でも)や、QImage, QPixmapなどの画像クラスで使用できます(正確にはQPaintDeviceというクラスを継承しているクラスです)

 QPainterの描画関数には例えば以下のような物があります。

drawRect, fillRect, drawLine, drawPolyline, drawPoint, drawEllipse, drawArc, drawPolygon, drawText, drawImage, drawPixmap ...

 名前を見れば大体何なのか分かるでしょうし、こういうものならば英語のドキュメントでも雰囲気で理解できるでしょう。また引数の取り方が違うオーバーロード関数がたくさんあって、状況に応じていろいろ使い分けることもできそうです。


 QPainterへの描画の際にはQPenというクラスで線や図形の輪郭線の色や太さ、線のパターン端やつなぎ目の形状などを設定することができます。


 同じくQBrushというクラスでは色だけでなく様々な塗りつぶしパターンを設定することができます。


 QFontでは文字のフォントをいろいろ指定できます。

 文字の色はQPenで設定された色になります。


 また上記のクラスで使用する色はQColorというクラスを使用し、これを使えばRGBをHSVに変換するようなことも簡単にできます。

内部イメージへの描画

 前節ではpaintEvent関数内でselfすなわちQmainWindowのPainterに描画を行いました。

 しかしpaintEventというのはウインドウの描画が必要になったとき呼ばれるものなので、例えばウインドウの上でダイアログを動かしていたりすると、ものすごい勢いで呼ばれ続けたりします。

 なのでここで複雑で時間のかかる描画はすることができません。


 また必要になったときということは、必要がなければ呼ばれないということです。

 例えばボタンを押したら何か描きたいと思っても、それだけでは何も起こらず、明示的にウインドウのupdate()関数を呼び出す必要があります。

 ならばと、ボタンのclicked()シグナルに対するスロットを作って、その中でPainterを取得して描画してupdateしてもなぜか画面には何も表示されません。

 QtではpaintEventが呼ばれる前には自動的に背景をきれいに塗りつぶしてくれるようなので、paintEventと異なったタイミングでウインドウのQPainterに何か描いても綺麗さっぱり消されてしまうようなのです。


 すなわち描画の遅い物や、描画タイミングをこちらで決めたいような場合、paintEventでMainWindowに直接描く方法は使えず、異なったやり方をしなければなりません。


 とはいっても別に難しいことはなく、QtにはQPixmapQImageといった画像イメージクラスがあり、そのクラスに対してQPainterが使えるようになっているということさえ分かれば、あとは以下の手順を行うだけです。

  1. まずQPixmapまたはQImageを作成します。その際にQPixmapでは画像のサイズ、QImageではそれに加えてカラーフォーマットを指定します(よく分からなければQImage.Format_ARGB32でOK)
  2. できた画像オブジェクトをパラメータにしてQPainterを作成します。
  3. そのQPainterを使うことで作った画像に描画できます。
  4. そうしておいてpaintEvent関数にはその画像をウインドウに描画するコードを書いておきます。

 以下はそのサンプルです。

def onButtonClick(self):                                        #ボタンを押したときのスロット
    self.img = Qg.QImage(500, 500, Qg.QImage.Format_ARGB32)     #500*500ピクセルの画像を作成
    imgcanvas = Qg.QPainter(self.img)                           #画像のQPainterを取得

    #imgcanvasに対していろいろな描画処理

    self.update()                                               #画面をアップデートする

def paintEvent(self, event):
    canvas = Qg.QPainter(self)                                  #メインウインドウのPainterに
    canvas.drawImage(0, 0, self.img)                            #上記のイメージを描画する

 こうすれば何かのボタンが押されたら画像がウインドウに描かれます。また、ウインドウが隠されたため再描画が必要なときにも自動的に描画されます。

◆QPixmapとQImage

 ところでイメージにQPixmapQImageの2種類があるというのはどういうことでしょうか。

 実はこれはほぼ同じような物なのですがQPixmapはスクリーンへの描画に、QImageの方はその他のデバイスへの描画やファイルセーブロードなどに最適化されているものです。


 すなわち画面描画を行う際にはQPixmapの方が高速に処理できるのですが、プリンターで印刷したりとかファイルに保存したり読み込んだりするような場合には、QImageの方が適しています。

 また画像のピクセルを直接操作したい場合、QImageにはそういった場合のsetPixelColor関数などが用意されていますが、QPixmapにはありません(QPainterのdrawPoint関数でできますが、やや効率は悪いようです)

 さらに後述のCompositionModeというものを扱いたいような場合も、QImageでないとフルサポートされないようです。


 ―――ということで、使い分けを考えるのが面倒ならQImageを使っているのがいいんじゃないでしょうか。QImageだから画面描画が泣くほど遅いというようなこともありません。しかしアニメーションしたいような場合ならQPixmapを使う方がいいでしょう。


 画面描画の基本は大体こんな物ですが、細かいところで分かりにくかったところを続いて何点か解説します。


グラデーション

 QtのQBrushにはグラデーションを描いてくれる機能がついています。

 これを使うにはQBrushを作成する際のパラメータとしてQGradientというグラデーション情報を定義したクラスのインスタンスを与えてやる必要があります。

 その基本的な概念は以下のような物です。

  1. コンストラクタでグラデーション開始位置と終了位置を指定します。
  2. 続いてsetColorAt(float, QColor)という関数で色を指定しますが、その際のfloatのパラメータが何かというと、開始位置と終了位置を線で結んで、開始位置を0.0、終了位置を1.0としたときのその中間点を表す場所です。

 例えば

    grad.setColorAt(0.1, Qg.QColor(Qt.red))
    grad.setColorAt(1.0, Qg.QColor(Qt.blue))

とすれば開始点が赤、終了点が青のグラデーションになりますがこの関数は途中にいくつでも挟むことができて

    grad.setColorAt(0.1, Qg.QColor(Qt.red))
    grad.setColorAt(0.5, Qg.QColor(Qt.green))
    grad.setColorAt(1.0, Qg.QColor(Qt.blue))

こんな風に書けば、赤から始まりちょうど中間地点で緑を経由して最終的に青になるグラデーションになります。


 グラデーションには線形、放射状、円錐形の3種類があって、それぞれのサブクラスQLinearGradientQRadialGradientQConicalGradientを使うことになります。

 線形グラデーションの場合はコンストラクタにグラデーション開始位置、終了位置を指定します。

 放射状の場合はまず基本円の中心、円の半径、グラデーション開始位置を与えます。グラデーション開始位置を省略すれば円の中心からのグラデーションになりますが、開始位置を与えてサンプルのようにずらしてやったら、光が当たったみたいなグラデーションになります。

 円錐形の場合は、中心位置と開始角度を与えます。終了角度は一周回って元の位置です。

以下はグラデーションのサンプルです。

def paintEvent(self, event):

    canvas = Qg.QPainter(self)                  #画面のPainterを取得する

    #線形グラデーション
    grad = Qg.QLinearGradient(25,0,75,100)      #座標(25,0)~(75,100)間で定義
    grad.setColorAt(0.1, Qg.QColor(Qt.red))     #色の位置の設定
    grad.setColorAt(0.5, Qg.QColor(Qt.green))
    grad.setColorAt(1.0, Qg.QColor(Qt.blue))
    canvas.setBrush(Qg.QBrush(grad))            #画面に描画する
    canvas.drawRect(0,0,100,100)

    #放射状グラデーション
    grad = Qg.QRadialGradient(150,50,50,160,60) #(150,50)を中心、半径50、(160,60)が開始位置
    grad.setColorAt(0.0, Qg.QColor(Qt.white))   #色を指定
    grad.setColorAt(1.0, Qg.QColor(Qt.blue))
    canvas.setBrush(Qg.QBrush(grad))            #画面への描画
    canvas.drawRect(100,0,100,100)

    #円錐形グラデーション
    grad = Qg.QConicalGradient(250, 50, 45)     #座標(250,50)を中心に45度角から開始
    grad.setColorAt(0.0, Qg.QColor(Qt.red))     #色の指定
    grad.setColorAt(0.33, Qg.QColor(Qt.green))
    grad.setColorAt(0.66, Qg.QColor(Qt.blue))
    grad.setColorAt(1.0, Qg.QColor(Qt.red))
    canvas.setBrush(Qg.QBrush(grad))            #画面への描画
    canvas.drawRect(200,0,100,100)




アルファブレンディング

 Qtにおいては画像データの扱いは非常に簡単で、QImageやQPixmapが画像ファイルのセーブロード機能を既に持っているので、それでデータをloadしてdrawImageやdrawPixmapで表示するだけです。

 画像を扱う場合、透明化の処理(アルファブレンディング)をする必要性もよくあると思いますが、これもほとんど何も考えなくてよくて、例えばフォトショップなどでアルファ設定した画像であれば単に読み込んで表示させるだけで勝手に透明になってくれます。

 それでは不透明の画像のアルファ値を設定したいときにはどうすればいいでしょうか?


 これが画像全体を一律に半透明化したいといった場合は簡単で、QPainterのsetOpacityという関数で透明度(0.0~1.0)を指定するだけです。そうすればその後の描画はずっと指定された透明度で半透明に描画されます。


 しかし、画像の一部だけを透明化したいような場合となると話が違ってきます。

 いろいろ調べてみると、QPixmapクラスにはsetAlphaChannelという関数があるのですが……

void QPixmap::setAlphaChannel(const QPixmap &p)


This function is deprecated.


Most use cases for this can be achieved using p with QPainter and QPainter::CompositionMode instead.

 ―――要するにこの関数は使うべきではなく、代わりにQPainter.CompositionModeを使え、ということのようなのですが、具体的に何をどうすればいいのか書いていません。


 このQPainter.CompositionModeというのを見に行くと、これは要するに画像を重ねる際の重ね方をどうするかを指定するものなのですが、三十何種類もあって最初はどれを選べばいいか正直困りました。

 でまあ、結果からいえば以下のようにするのが一番分かりやすいのではないかと思います。

 以下の例はsample1.pngという渦巻きみたいな画像と、sample2.jpgという仏像が写った写真を合成しています。

def paintEvent(self, event):

    canvas = Qg.QPainter(self)                      #画面のPainterを取得する

    self.img1 = Qg.QImage('sample1.png')            #渦巻き画像
    self.img2 = Qg.QImage('sample2.jpg')            #仏像写真
    canvas.drawImage(0, 0, self.img1)               #プレビューのため表示
    canvas.drawImage(200, 0, self.img2)

    self.img3 = Qg.QImage(
            200, 200, Qg.QImage.Format_ARGB32)      #渦巻き画像にアルファチャンネルがないので
    img3canvas = Qg.QPainter(self.img3)             #アルファのある下地を作り(補足
    img3canvas.drawImage(0, 0, self.img1)           #そこに渦巻きを上書きして作業する

    img3canvas.setBrush(Qg.QColor('#50000000'))     #Painterに半透明色を設定
    img3canvas.setCompositionMode(
            canvas.CompositionMode_DestinationIn)   #CompositionModeを設定
    img3canvas.drawEllipse(40, 40, 120, 120)        #画像中央に円を描く

    canvas.drawImage(400, 0, self.img2)             #仏像を描画
    canvas.drawImage(400, 0, self.img3)             #そのうえから処理した渦巻きを描画




 渦巻き画像の真ん中の丸い部分だけ後ろに仏像が透けているのが見えると思います。


 上記のやり方のキモはQPainterのCompositionModeをQPainter.CompositionMode_DestinationInという長い名前の値に設定していることです。こうすることで画像のアルファ値だけが描画されます。

◆画像の形式変更

 これで半透明化は完了かと思ったら、画像によっては上のやり方でも透明化できない物があります。これは読み込んだ画像のカラーフォーマットが元々アルファチャンネルに対応していないような場合で、そんな画像にいくらアルファを重ねても透明にはなりません。

 上記サンプルの場合渦巻き画像がそうだったのでそれを変換する処理が入っています。


 そのような場合に簡単に形式を変更するためには、最初にQImage.Format_ARGB32で必要な大きさのブランク画像を作成しておき、読み込んだ画像をそのブランク画像の上にdrawImageしてやれば下の画像のカラーフォーマットが優先されるためアルファが設定できるようになります。

独自の描画領域を作りたい場合1:Labelの使用

 さてこれまでは描画はメインウインドウに対して行ってきました。簡単なツールやサンプルならそれで問題ないでしょうが、いろいろなUIをつけ加えていったりすると、画面のある一部だけを描画領域にしたくなることも多いでしょう。

 QImageやQPixmapなどを作って位置を指定して置けば変なはみ出しとかはありませんが、QtDesignerでレイアウトしたりすることができません。

 そこでちょっと便利なのがラベルの使用です。


 QLabelクラスはQtDesignerで画面上に好きな場所に好きな大きさで置けますが、これにはsetPixmap(QPixmap)というスロットがあって、任意のQPixmapを設定することができます。そうするとラベルが自動的にその画像を表示してくれるようになります。


独自の描画領域を作りたい場合2:オリジナルPaintBox

 しかしラベルの場合、軽く表示したいような場合にはいいのですがQImageを使うことはできないし、その上でマウスで何か作業したいような状況になると、結局サブクラスを作らなければなりません。

 ところがちょっと座標を調べたいとか、マウス位置の色が知りたい程度の些細なことのためにいちいちサブクラスを作るというのも気が引ける話です。

 これがDelphiやVBとかだとペイントボックスという、フォーム上に置けばその領域のcanvasが取得できて、ローカル座標でマウスイベントなんかも取れるシンプルなコントロール(=ウィジェット)があります。しかしQtにはどうもそういう物はないようです。

 そこであれば便利かと思って作ってみたのが以下のTPaintWidgetです。


 これが何をするものかと言えば、ペイントイベントやマウスイベントが来たらシグナルを発して他に丸投げするというだけのウィジェットです。


 そのためにはまず自前のシグナルを定義する必要がありますが、それは下記にあるようにQc.pyqtSignalという関数を使います。

    onPaint = Qc.pyqtSignal(Qg.QPaintEvent, Qg.QPainter)

 この例ではonPaintという名のシグナルを定義していて、そのシグナルはQg.QPaintEvent, Qg.QPainterという型の二つの引数を取ることを示しています。

 続いて以下の例のようにシグナルの発行はemitという関数を使います。その引数に上で定義された値を取っていることに注意してください。

    def paintEvent(self, event: Qg.QPaintEvent):
        canvas = Qg.QPainter(self)
        self.onPaint.emit(event, canvas)                #シグナルの発行

 そんな感じでマウスイベントなども含めて投げているのが以下のクラスです。

# -*- coding: utf-8 -*-
from PyQt5 import QtCore as Qc, QtGui as Qg, QtWidgets as Qw

#QObjectを継承しなければいけない(QWidgetは大丈夫)
class TPaintWidget(Qw.QWidget):

    #シグナル定義。パラメータの「型」を引数にする
    onPaint = Qc.pyqtSignal(Qg.QPaintEvent, Qg.QPainter)
    onMousePress = Qc.pyqtSignal(Qg.QMouseEvent)
    onMouseMove = Qc.pyqtSignal(Qg.QMouseEvent)
    onMouseRelease = Qc.pyqtSignal(Qg.QMouseEvent)
    onDoubleClick = Qc.pyqtSignal(Qg.QMouseEvent)

    def paintEvent(self, event: Qg.QPaintEvent):
        canvas = Qg.QPainter(self)
        self.onPaint.emit(event, canvas)                #シグナルの発行

    def mousePressEvent(self, event: Qg.QMouseEvent):
        self.onMousePress.emit(event)

    def mouseMoveEvent(self, event: Qg.QMouseEvent):
        self.onMouseMove.emit(event)

    def mouseReleaseEvent(self, event: Qg.QMouseEvent):
        self.onMouseRelease.emit(event)

    def mouseDoubleClickEvent(self, event: Qg.QMouseEvent):
        self.onDoubleClick.emit(event)

 上記をコピペして適当な名前のモジュールとして保存してもらえば、後は前回やったウィジェットの格上げという操作で、QtDesigner上でレイアウトできます。

 またここで定義されている新しいシグナルを使うには、これも前回のスロットの自作で行ったのと全く同じ手順でシグナルの追加を行ってやればいいだけです。

◆格上げ情報のロード

 ところで自前のサブクラスを使うたびに格上げするのは仕方ないにしても、シグナルの追加まで毎回やるのはけっこう面倒です。しかしQtDesignerはそのあたりの設定を中途半端に覚えていて―――というのはあるフォームを編集してQtDesignerを終了させずに別なフォームを読み込んだような場合には、前のフォームで設定したシグナルも含めた格上げ情報が残っているのです。

 なので作業前に一旦そういう設定済みのフォームを読んでから作業を開始すれば、自前で定義したシグナルとかを毎回再定義せずに済みます。


ということで……

 色々細かいところは豪快にすっとばしていますが、このぐらいが分かれば2D表示に関してはそれなりに何とかなるのではないでしょうか。

 もちろんQtの描画関連はこれで終わりではなく、QPictureといってメタファイルを扱うような物や、QOpenGL***なんて物もあります。またQtDesignerにもあるQGraphicsViewQGraphicsSceneを使ったら、もっと色々できるみたいです。

 GraphicsViewの方はそのうちやるかもしれませんが、3D関係になったらもう全然お手上げなので、みなさん適宜研究して下さいw


 ということで次回はデザイナーのレイアウトのことをやろうかと思います。


2017-04-06