PyQt5のQGraphicsViewにマップを表示するQGraphicsItemを作ってみた

2021/10/30 categories:PyQt5| tags:PyQt5|Python|

カスタムQGraphicsItemを作ってQGraphicsViewに表示してみました。

表示する内容

GARMINの.fitデータを動画に変換するツールrev1に走行ログの経度と緯度をマップとして表示するために、その表示用のQGraphicsItemを作ってみました。表示内容は以下のような内容にします。

単純な線の表示はQGraphicsPathItemを使うことで実現可能ですが、その線にアウトラインを付けて表示したり、線の頂点に円を表支するとなると一つのアイテムでは表示できないためカスタムアイテムとして実装してみました。

QGraphicsItemを継承してクラスを作成する

QGraphicsItemを継承するクラスを作成するためにはpaint()メソッドとboundingRect()をオーバーライドすれば良いようです。paint()は描画処理時に呼び出される関数のようです。従って、この関数をオーバーライドして表示の処理のみ記述すればよいと考えていましたが、boundingRect()もオーバーライドして表示の範囲を返すような処理を記述しなければ期待したような処理が出来ませんでした。boundingRect()もオーバーライドなしでの表示は以下の通りです。

この時のコードは以下の通りです。

class Map(QtWidgets.QGraphicsItem):
    def __init__(self, x_arr, y_arr):
        super().__init__()

        self.index = 0
        self.polygon = QtGui.QPolygonF()
        for x, y in zip(x_arr, y_arr):
            self.polygon.append( QtCore.QPointF(x, y) )

        self.ellipse_size = 20
        self.ellipse_brush = QtGui.QBrush( QtGui.QColor('#FF0000') )
        self.ellipse_pen = QtGui.QPen( QtGui.QBrush( QtGui.QColor('#000000') ), 10 )

        self.outline = QtGui.QPainterPath()
        self.outline.addPolygon(self.polygon)
        self.outline_pen = QtGui.QPen( QtGui.QBrush( QtGui.QColor('#000000') ), 10 )

        self.inline = QtGui.QPainterPath()
        self.inline.addPolygon(self.polygon)
        self.inline_pen = QtGui.QPen( QtGui.QBrush( QtGui.QColor('#FFFFFF') ), 6 )

    def paint(self, painter, option, widget=None):
        painter.setPen(self.outline_pen)
        painter.drawPath(self.outline)
        
        painter.setPen(self.inline_pen)
        painter.drawPath(self.inline)

        x, y, s = self.polygon.at(self.index).x(), self.polygon.at(self.index).y(), self.ellipse_size
        painter.setBrush(self.ellipse_brush)
        painter.setPen(self.ellipse_pen)
        painter.drawEllipse(int(x - s/2), int(y - s/2), int(s), int(s))

    def update_item(self, index):
        self.index = index
        self.update()

QGraphicsItemは、paint()でアウトラインのQPainterPathとインラインのQPainterPathを表示して、そのラインの現在地として円を表示する為にdrawEllipseで円を表示しています。また、スライダーが変化した時にupdate_itemで円の描画位置を変更するようにしています。

このようなクラスでは、表示範囲以上にズームインしたときに表示が消えてしまうような挙動になります。boundingRect()を継承してアイテムの表示範囲を返すような処理を実装すればこのような動作は回避できるようです。

boundingRect()の処理

boundingRect()関数では、描画範囲を計算してQRectFとして返すような処理を行います。今回はQGraphicsItemで2つの図形を描画するのでそれらの最大描画範囲を計算することになります。コードは下記の通りで、それぞれの図形のxとy座標の最小値と最大値を計算するような処理にしました。

def boundingRect(self):
    inline_x1 = self.inline.boundingRect().topRight().x()
    inline_y1 = self.inline.boundingRect().topRight().y()
    inline_x2 = self.inline.boundingRect().bottomLeft().x()
    inline_y2 = self.inline.boundingRect().bottomLeft().y()

    outline_x1 = self.outline.boundingRect().topRight().x()
    outline_y1 = self.outline.boundingRect().topRight().y()
    outline_x2 = self.outline.boundingRect().bottomLeft().x()
    outline_y2 = self.outline.boundingRect().bottomLeft().y()

    r = self.ellipse_size/2 + self.ellipse_pen.width()
    x = [inline_x1, inline_x2, outline_x1, outline_x2]
    y = [inline_y1, inline_y2, outline_y1, outline_y2]
    w = max(x) - min(x) + r * 2
    h = max(y) - min(y) + r * 2
    rect = QtCore.QRectF(min(x) - r, min(y) - r, w, h)

    return rect

描画結果

ソースコード

# -*- coding: utf-8 -*-
import fitdecode
import sys
import numpy as np
from PyQt5 import QtWidgets, QtCore, QtGui

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, parent=None):
        super(MainWindow, self).__init__(parent)
        self.resize(800, 800)
        
        self.slider = QtWidgets.QSlider()
        self.slider.setOrientation(QtCore.Qt.Horizontal)
        self.graphics_view = GraphicsView()

        self.setCentralWidget( QtWidgets.QWidget() )
        self.centralWidget().setLayout( QtWidgets.QVBoxLayout() )
        self.centralWidget().layout().addWidget(self.graphics_view)
        self.centralWidget().layout().addWidget(self.slider)

        self.scene = QtWidgets.QGraphicsScene(0, 0, 1000, 1000)
        self.graphics_view.setScene(self.scene)

        # open garmin fit data
        x, y = [], []
        with fitdecode.FitReader('test_fit_data.fit', processor=fitdecode.StandardUnitsDataProcessor()) as fit:
            for frame in fit:
                if not isinstance(frame, fitdecode.FitDataMessage):
                    continue
                if not frame.name == 'record':
                    continue
                if not frame.has_field('position_lat'):
                    continue
                if not frame.has_field('position_long'):
                    continue
                x.append( frame.get_field('position_lat').value )
                y.append( frame.get_field('position_long').value )
        x, y = np.array(x), np.array(y)
        x, y = x - min(x), y - min(y) # offset values
        if max(x) > max(y): # change the value range
            x, y = x * 1000 / max(x), y * 1000 / max(x)
        else:
            x, y = x * 1000 / max(y), y * 1000 / max(y)
        
        self.slider.setMaximum( len(x)-1 )
        self.map = Map(x, y)
        self.scene.addItem(self.map)
        
        self.slider.valueChanged.connect( lambda x : self.map.update_item(x) )
        
class Map(QtWidgets.QGraphicsItem):
    def __init__(self, x_arr, y_arr):
        super().__init__()

        self.index = 0
        self.polygon = QtGui.QPolygonF()
        for x, y in zip(x_arr, y_arr):
            self.polygon.append( QtCore.QPointF(x, y) )

        self.ellipse_size = 20
        self.ellipse_brush = QtGui.QBrush( QtGui.QColor('#FF0000') )
        self.ellipse_pen = QtGui.QPen( QtGui.QBrush( QtGui.QColor('#000000') ), 10 )

        self.outline = QtGui.QPainterPath()
        self.outline.addPolygon(self.polygon)
        self.outline_pen = QtGui.QPen( QtGui.QBrush( QtGui.QColor('#000000') ), 10 )

        self.inline = QtGui.QPainterPath()
        self.inline.addPolygon(self.polygon)
        self.inline_pen = QtGui.QPen( QtGui.QBrush( QtGui.QColor('#FFFFFF') ), 6 )

    def boundingRect(self):
        inline_x1 = self.inline.boundingRect().topRight().x()
        inline_y1 = self.inline.boundingRect().topRight().y()
        inline_x2 = self.inline.boundingRect().bottomLeft().x()
        inline_y2 = self.inline.boundingRect().bottomLeft().y()

        outline_x1 = self.outline.boundingRect().topRight().x()
        outline_y1 = self.outline.boundingRect().topRight().y()
        outline_x2 = self.outline.boundingRect().bottomLeft().x()
        outline_y2 = self.outline.boundingRect().bottomLeft().y()

        r = self.ellipse_size/2 + self.ellipse_pen.width()
        x = [inline_x1, inline_x2, outline_x1, outline_x2]
        y = [inline_y1, inline_y2, outline_y1, outline_y2]
        w = max(x) - min(x) + r * 2
        h = max(y) - min(y) + r * 2
        rect = QtCore.QRectF(min(x) - r, min(y) - r, w, h)

        return rect
##
    def paint(self, painter, option, widget=None):
        painter.setPen(self.outline_pen)
        painter.drawPath(self.outline)
        
        painter.setPen(self.inline_pen)
        painter.drawPath(self.inline)

        x, y, s = self.polygon.at(self.index).x(), self.polygon.at(self.index).y(), self.ellipse_size
        painter.setBrush(self.ellipse_brush)
        painter.setPen(self.ellipse_pen)
        painter.drawEllipse(int(x - s/2), int(y - s/2), int(s), int(s))

    def update_item(self, index):
        self.index = index
        self.update()

class GraphicsView(QtWidgets.QGraphicsView):
    mouseLeftReleased = QtCore.pyqtSignal(QtCore.QRectF)
    def __init__(self, parent=None):
        super(GraphicsView, self).__init__(parent)
        self.numScheduledScalings = 0
        self.rectF = QtCore.QRectF(0.0, 0.0, 0.0, 0.0)
        self.setBackgroundBrush(QtGui.QColor(210, 210, 210))

    def animation_finished(self):
        if self.numScheduledScalings > 0:
            self.numScheduledScalings -= 1
        else:
            self.numScheduledScalings += 1

    def wheelEvent(self, event):
        numDegrees = event.angleDelta().y() / 8
        numSteps = numDegrees / 15
        self.numScheduledScalings += numSteps
        if self.numScheduledScalings * numSteps < 0:
            self.numScheduledScalings = numSteps
        anim = QtCore.QTimeLine(350, self)
        anim.setUpdateInterval(20)
        anim.valueChanged.connect(self.scaling_time)
        anim.finished.connect(self.animation_finished)
        anim.start()

    def mousePressEvent(self, event):
        if event.button() == QtCore.Qt.MidButton:
            self.setDragMode(QtWidgets.QGraphicsView.ScrollHandDrag)

            event = QtGui.QMouseEvent(
                QtCore.QEvent.GraphicsSceneDragMove, 
                event.pos(), 
                QtCore.Qt.MouseButton.LeftButton, 
                QtCore.Qt.MouseButton.LeftButton, 
                QtCore.Qt.KeyboardModifier.NoModifier
            )

        elif event.button() == QtCore.Qt.LeftButton:
            self.setDragMode(QtWidgets.QGraphicsView.RubberBandDrag)
            point = self.mapToScene( event.pos() )
            self.rectF = QtCore.QRectF(point, point)

        super().mousePressEvent(event)
        
    def mouseReleaseEvent(self, event):
        super().mouseReleaseEvent(event)

        self.setDragMode(QtWidgets.QGraphicsView.NoDrag)

        if event.button() == QtCore.Qt.LeftButton:
            p2 = self.mapToScene( event.pos() )
            self.rectF.setBottomRight(p2)
            self.mouseLeftReleased.emit(self.rectF)

    def scaling_time(self, x):
        factor = 1.0 + float(self.numScheduledScalings) / 300.0
        self.scale(factor, factor)

if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    window = MainWindow()
    window.show()
    app.exec()

Share post

Related Posts

Comments

comments powered by Disqus