※当サイトではアフィリエイト広告を利用しています。

GUI開発

【PyQt5】画像に四角形を動的に描画する方法

こんにちは、タナカです。

今回は、PyQt5で画像に四角形を動的に描画する方法を紹介します。

ここでの動的とは、自分がマウスで選択した位置から好きなサイズで四角形を作れることを言います。

具体的には下図のようにクリックした位置から、クリックを離した範囲で四角形を描画することができます。

この記事の内容
  • PyQt5で画像に四角形を描画する方法がわかる

1. ウィンドウに画像を表示する方法

ウィンドウに画像を表示させる方法はpaintEventを使って画像をウィンドウ上に表示させる方法で紹介していますので、詳しい実装方法など参考にしてください。

そちらの記事で作成したコードに追記する形で実装していきます。

2. Shapeクラスの実装

新たに四角形を描画するためのShapeクラスを実装します。Shapeクラスはクリックした位置と離した位置をもとに四角形を描画します。

Shapeクラスのコードはこのようになります。コードの内容はコメントを参照してください。

# 四角を描画するためのShapeクラス
class Shape(object):
    def __init__(self):
        # 初期値
        self.scale = 1.0
        self.point_size = 8
        self.points = []
        self.rectangle_color = QtGui.QColor(0, 255, 0, 128)

    # Shapeクラスのインスタンスからリストのように要素を指定して、自分の欲しい情報を取得できる
    def __getitem__(self, key):
        return self.points[key]

    # Canvasで実装したQPainterを受け取って四角を描画する関数
    def paint(self, painter):
        # pointsがTrueの場合
        if self.points:
            # paintEventで使用するpenを設定
            pen = QtGui.QPen(self.rectangle_color)
            pen.setWidth(max(1, int(round(2.0 / self.scale))))
            painter.setPen(pen)

            # ウィンドウに可視化するためのパス(描画するための道のりやアウトラインのイメージ)
            line_path = QtGui.QPainterPath()
            vrtx_path = QtGui.QPainterPath()

            if len(self.points) == 2:
                # 四角形の情報(x, y, w, h)
                rectangle = self.getRectFromLine(*self.points)
                line_path.addRect(rectangle)
                # クリックした位置と離した位置を丸で表現
                for i in range(len(self.points)):
                    self.drawVrtx(vrtx_path, i)
            
            # ウィンドウ上に描画
            painter.drawPath(line_path)
            painter.drawPath(vrtx_path)

    # クリックした位置と離した位置をアウトラインとして提供する関数
    def drawVrtx(self, path, i):
        d = self.point_size / self.scale
        point = self.points[i]
        path.addEllipse(point, d / 2.0, d / 2.0)

    # クリックした位置を保持する関数
    def addPoint(self, point):
        self.points.append(point)

    # クリックした位置と離した位置から四角形の情報を取得する関数
    def getRectFromLine(self, point1, point2):
        x1, y1 = point1.x(), point1.y()
        x2, y2 = point2.x(), point2.y()
        w = x2 - x1
        h = y2 - y1

        return QtCore.QRectF(x1, y1, w, h)

3. Canvasクラスの追記

前回作成したCanvasクラスに先ほど作ったShapeクラスを使って追記していきます。追記した個所はコメントで分かるようにしています。

class Canvas(QWidget):
    def __init__(self):
        super(Canvas, self).__init__()

        # 初期値
        self.painter = QtGui.QPainter()
        self.pixmap = QtGui.QPixmap()
        self.scale = 1.0

        ## 追記 ##
        self.shapes = []
        self.rectangle = Shape()
        self.current = None

        # canvasのレイアウト
        self.canvas_layout = QGridLayout()
        
        # canvas_layoutをQWidget(self)にセット
        self.setLayout(self.canvas_layout)


    def openImage(self, filepath):
        img = QtGui.QImage()
        
        # 画像ファイルの読み込み
        if not img.load(filepath):
            return False

        # QImage -> QPixmap
        self.pixmap = QtGui.QPixmap.fromImage(img)

    def paintEvent(self, event):
        if not self.pixmap:
            return super(Canvas, self).paintEvent(event)

        # paintオブジェクトの生成(描画するためのもの)
        p = self.painter

        # paintができる状態にする
        p.begin(self)

        # 画像のスケール情報
        p.scale(self.scale, self.scale)

        # 原点の設定
        p.translate(self.offsetToCenter())

        # 画像を描画する
        p.drawPixmap(0, 0, self.pixmap)


        ## 追記 ##
        Shape.scale = self.scale
        # 四角を描画
        if self.current:
            self.current.paint(p)
            self.rectangle.paint(p)
            
        # 描画したものを表示
        for shape in self.shapes:
            shape.paint(p)
        

        # paintの終了
        p.end()
    
    # 原点補正
    def offsetToCenter(self):
        scale = self.scale 
        area = super(Canvas, self).size()
        w, h = self.pixmap.width() * scale, self.pixmap.height() * scale
        aw, ah = area.width(), area.height()
        x = (aw - w) / (2 * scale) if aw > w else 0
        y = (ah - h) / (2 * scale) if ah > h else 0

        return QtCore.QPoint(int(x), int(y))


    ## ここより下 追記 ##
    # press_event(マウスをクリックしたときに発生)
    def mousePressEvent(self, event):
        # クリックした場所の位置(x, y)を取得
        pos = self.transformPos(event.localPos())

        # 左クリックが押された場合
        if event.button() == QtCore.Qt.LeftButton:
            self.current = Shape()
            self.current.addPoint(pos)

    # move_event(クリックした状態でマウスを移動したときに発生)
    def mouseMoveEvent(self, event):
        pos = self.transformPos(event.localPos())
        if not self.current:
            return
        # 四角を描画するための位置情報
        self.rectangle.points = [self.current[0], pos]
        
        # 四角を可視化するために必要
        self.repaint()

    # release_event(クリックを離したときに発生)
    def mouseReleaseEvent(self, event):
        if self.current:
            self.current.points = self.rectangle.points
            self.initialize()
    
    # 初期化関数
    def initialize(self):
        self.shapes = [self.current]
        self.current = None
        self.update()

    # 画像左上を(0, 0)に補正
    def transformPos(self, point):
        return point / self.scale - self.offsetToCenter()

 

マウス操作をした際のevent関連を追加しています。

特にマウスでクリックしたタイミング、マウスをクリックした状態で移動させたタイミング、クリックを離したタイミングでeventが発生します。

4. 最終的なコード

完成したコードを示します。

from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QAction, QFileDialog, QGridLayout, QSpinBox
from PyQt5 import QtGui
from PyQt5 import QtCore

import sys
import os

class MainWindow(QMainWindow):
    def __init__(self):
        super(MainWindow, self).__init__()

        # 初期値
        self.setGeometry(0, 0, 500, 500)
        self.image = QtGui.QImage()
        
        # canvas
        self.canvas = Canvas()
        self.setCentralWidget(self.canvas)

        # menubarを作成
        self.createMenubar()

        # zoomwidge
        self.zoomWidget = ZoomWidget()
        self.zoomWidget.valueChanged.connect(self.paintCanvas)

    def createMenubar(self):
        # menubar
        self.menubar = self.menuBar()

        # menubarにメニューを追加
        self.filemenu = self.menubar.addMenu('File')

        # アクションの追加
        self.openAction()

    def openAction(self):
        # アクションの作成
        self.open_act = QAction('開く')
        self.open_act.setShortcut('Ctrl+O') # shortcut
        self.open_act.triggered.connect(self.openFile) # open_actとメソッドを紐づける

        # メニューにアクションを割り当てる
        self.filemenu.addAction(self.open_act)

    def openFile(self):
        self.filepath = QFileDialog.getOpenFileName(self, 'open file', '', 'Images (*.jpeg *.jpg *.png *.bmp)')[0]
        if self.filepath:
            self.canvas.openImage(self.filepath)
            self.paintCanvas()

    def paintCanvas(self):
        # canvasのスケールを更新
        self.canvas.scale = self.scaleFitWindow()
        self.canvas.update()

    def scaleFitWindow(self):
        # MainWindowのウィンドウサイズ
        e = 2.0 # 余白
        w1 = self.centralWidget().width() - e
        h1 = self.centralWidget().height() - e
        a1 = w1 / h1
        
        # pixmapのサイズ
        w2 = self.canvas.pixmap.width()
        h2 = self.canvas.pixmap.height()
        a2 = w2 / h2

        # a1が大きい -> 高さに合わせる
        # a2が大きい -> 幅に合わせる
        return w1 / w2 if a2 >= a1 else h1 / h2

    def adjustScale(self):
        value = self.scaleFitWindow()
        value = int(100 * value)
        self.zoomWidget.setValue(value)

    def resizeEvent(self, event):
        # canvasがTrue かつ pixmapがnullじゃない場合
        if self.canvas and not self.canvas.pixmap.isNull():
            self.adjustScale()
        super(MainWindow, self).resizeEvent(event)

class Canvas(QWidget):
    def __init__(self):
        super(Canvas, self).__init__()

        # 初期値
        self.painter = QtGui.QPainter()
        self.pixmap = QtGui.QPixmap()
        self.scale = 1.0

        ## 追記 ##
        self.shapes = []
        self.rectangle = Shape()
        self.current = None

        # canvasのレイアウト
        self.canvas_layout = QGridLayout()
        
        # canvas_layoutをQWidget(self)にセット
        self.setLayout(self.canvas_layout)

    def openImage(self, filepath):
        img = QtGui.QImage()
        
        # 画像ファイルの読み込み
        if not img.load(filepath):
            return False

        # QImage -> QPixmap
        self.pixmap = QtGui.QPixmap.fromImage(img)

    def paintEvent(self, event):
        if not self.pixmap:
            return super(Canvas, self).paintEvent(event)

        # paintオブジェクトの生成(描画するためのもの)
        p = self.painter

        # paintができる状態にする
        p.begin(self)

        # 画像のスケール情報
        p.scale(self.scale, self.scale)

        # 原点の設定
        p.translate(self.offsetToCenter())

        # 画像を描画する
        p.drawPixmap(0, 0, self.pixmap)


        ## 追記 ##
        Shape.scale = self.scale
        # 四角を描画
        if self.current:
            self.current.paint(p)
            self.rectangle.paint(p)
            
        # 描画したものを表示
        for shape in self.shapes:
            shape.paint(p)
        

        # paintの終了
        p.end()
    
    # 原点補正
    def offsetToCenter(self):
        scale = self.scale 
        area = super(Canvas, self).size()
        w, h = self.pixmap.width() * scale, self.pixmap.height() * scale
        aw, ah = area.width(), area.height()
        x = (aw - w) / (2 * scale) if aw > w else 0
        y = (ah - h) / (2 * scale) if ah > h else 0

        return QtCore.QPoint(int(x), int(y))


    ## ここより下 追記 ##
    # press_event(マウスをクリックしたときに発生)
    def mousePressEvent(self, event):
        # クリックした場所の位置(x, y)を取得
        pos = self.transformPos(event.localPos())

        # 左クリックが押された場合
        if event.button() == QtCore.Qt.LeftButton:
            self.current = Shape()
            self.current.addPoint(pos)

    # move_event(クリックした状態でマウスを移動したときに発生)
    def mouseMoveEvent(self, event):
        pos = self.transformPos(event.localPos())
        if not self.current:
            return
        # 四角を描画するための位置情報
        self.rectangle.points = [self.current[0], pos]
        
        # 四角を可視化するために必要
        self.repaint()

    # release_event(クリックを離したときに発生)
    def mouseReleaseEvent(self, event):
        if self.current:
            self.current.points = self.rectangle.points
            self.initialize()

    # 初期化関数
    def initialize(self):
        self.shapes = [self.current]
        self.current = None
        self.update()

    # 画像左上を(0, 0)に補正
    def transformPos(self, point):
        return point / self.scale - self.offsetToCenter()
    

class ZoomWidget(QSpinBox):
    def __init__(self, value=100):
        super(ZoomWidget, self).__init__()

# 四角を描画するためのShapeクラス
class Shape(object):
    def __init__(self):
        # 初期値
        self.scale = 1.0
        self.point_size = 8
        self.points = []
        self.rectangle_color = QtGui.QColor(0, 255, 0, 128)

    # リストのようにShapeクラスのインスタンスから取得する情報
    def __getitem__(self, key):
        return self.points[key]

    # Canvasで実装したQPainterを受け取って四角を描画する関数
    def paint(self, painter):
        # pointsがTrueの場合
        if self.points:
            # paintEventで使用するpenを設定
            pen = QtGui.QPen(self.rectangle_color)
            pen.setWidth(max(1, int(round(2.0 / self.scale))))
            painter.setPen(pen)

            # ウィンドウに可視化するための位置情報
            line_path = QtGui.QPainterPath()
            vrtx_path = QtGui.QPainterPath()

            if len(self.points) == 2:
                # 四角形の情報(x, y, w, h)
                rectangle = self.getRectFromLine(*self.points)
                line_path.addRect(rectangle)
                # クリックした位置と離した位置を丸で表現
                for i in range(len(self.points)):
                    self.drawVrtx(vrtx_path, i)
            
            # ウィンドウ上に描画
            painter.drawPath(line_path)
            painter.drawPath(vrtx_path)

    # クリックした位置と離した位置をアウトラインとして提供する関数
    def drawVrtx(self, path, i):
        d = self.point_size / self.scale
        point = self.points[i]
        path.addEllipse(point, d / 2.0, d / 2.0)

    # クリックした位置を保持する関数
    def addPoint(self, point):
        self.points.append(point)

    # クリックした位置と離した位置から四角形の情報を取得する関数
    def getRectFromLine(self, point1, point2):
        x1, y1 = point1.x(), point1.y()
        x2, y2 = point2.x(), point2.y()
        w = x2 - x1
        h = y2 - y1

        return QtCore.QRectF(x1, y1, w, h)

def main():
    app = QApplication(sys.argv)
    win = MainWindow()
    win.show()
    sys.exit(app.exec_())

main()

まとめ

pyqt5を使って画像に四角形を描画する方法を紹介しました。

マウスを使って直感的に操作しようとするとevent関連の関数を使うことになります。

ここまで来るとソースコードもかなり長くなってしまいました。

また、今の状態ですとウィンドウ内のどこでも四角を描画することができるため、次回の記事で画像内のみ描画できるようにします。