フォルダ内のCSVファイルをグラフとして一覧表示するツールを作ってみた

フォルダ内のCSVファイルをPyQtGraphを使ってグラフ化するツールを作ってみました。テストに使用したCSVファイルは5日分の気象データです。

機能

実装した機能は以下の通りです。フォルダ内を監視してCSVが追加されたら自動でグラフ化して表示するというツールが欲しかったので作ってみました。エクセルで作ってもよかったのですが、PyQt5で作った方がスマートかなと思ってPythonで作成しました。

フォルダの監視

PyQt5のQFileSystemWatcherでフォルダを監視するテストで作成したプログラムを流用して、フォルダ内のファイル変更をチェックしました。下記コードのように、自作のFileListクラス内でQFileSystemWatcherオブジェクトを作成して、QFileSystemWatcher.directoryChangedのシグナルにdirectoryChangedSlotをconnectして、ファイルの変更があった場合に、FileListクラスがdirectoryChangedシグナルを発行するようにしました。directoryChangedSlot内では、FileListクラス内の処理を行い、directoryChangedシグナルは他のクラスなどで処理をする際に利用するという構造にしています。このようにQFileSystemWatcherでファイルの変更を監視して、CSVファイルが追加されたときに自動でCSVを読み込み、グラフ化して表示するという処理を実現しています。

class FileList(QtWidgets.QWidget):
    directoryChanged = QtCore.pyqtSignal()
    def __init__(self, parent=None, path=None):
        self.fileSystemWatcher = QtCore.QFileSystemWatcher(self)
        self.fileSystemWatcher.directoryChanged.connect(self.directoryChangedSlot)

    def directoryChangedSlot(self, string):
        self.directoryChanged.emit()

CSVをグラフ化

csvをpandasで読み込み、DataFrameの列ごとにPlotDataItemとしてPlotWidgetに追加してグラフを表示しています。PlotWidgetにLinearRegionItemを追加することで、グラフ上範囲指定のGUIを表示するようにしています。これによって範囲をマウスで操作して、その範囲内の最大値と最小値を表示できるようにしました。これらを表示するオブジェクトは自作のGraphクラスで、グラフの表示以外に、表示するY軸データの種類の指定や、最大値と最小値の表示、範囲指定するデータの種類の指定などが出来るように作成しています。

class Graph(QtWidgets.QWidget):
    def __init__(self, parent=None, title=None):
        super(Graph, self).__init__(parent)
        self.plotWidget = pg.PlotWidget(self.groupBox)
        
    def changeXAxis(self, xKey=''):
        for column in self.df.columns:
            self.plotItem[column] = pg.PlotDataItem(
                x=self.df[self.xKey], y=self.df[column], pen=pg.mkPen(color='#000000', width=1), antialias=True
            )
            self.plotWidget.addItem( self.plotItem[column] )
            
            linear_region_item = pg.LinearRegionItem(( self.df[self.xKey].min(), self.df[self.xKey].max() ), 'vertical')
            self.plotWidget.addItem(linear_region_item)

フォルダ内にCSVが追加されたら自動でグラフを追加

フォルダ内のCSVは自作のGraphListに表示されます。このクラスにはQScrollAreaがセットされていて、addGraphでQScrollAreaにGraphクラスが追加されていくことで表示されるグラフが増えていくという構造になっています。FileListのdirectoryChangedが発行されると、一番トップのクラスであるMainWindowクラスがdirectoryChangedを実行して、GraphListのaddGraphを実行することでグラフを追加しています。

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, parent=None):
        super(MainWindow, self).__init__(parent)
        self.fileList.directoryChanged.connect(self.directoryChanged)
        
    def directoryChanged(self):
        for path in self.fileList.paths():
            graph = self.graphList.addGraph(path, path.name, xKey)

class GraphList(QtWidgets.QWidget):
    def __init__(self, parent=None):
        super(GraphList, self).__init__(parent)
        scrollArea = QtWidgets.QScrollArea(self)
        self.scrollLayout = QtWidgets.QVBoxLayout(self.scrollWidget)
        self.scrollLayout.addItem(spacerItem)

    def addGraph(self, csv_path, title, xKey):
        graph = Graph(self.scrollWidget, title)
        graph.setDataFrame(pd.read_csv(csv_path, encoding=csv_encoding), xKey)
        self.scrollLayout.insertWidget(self.scrollLayout.count() - 1, graph)

グラフを範囲指定して最大値と最小値を表示

PlotWidgetにはLinearRegionItemが追加されていて、マウス操作で範囲指定できるようにしています。この範囲が変更されたときにsigRegionChangeFinishedシグナルが発行されるので、そのシグナルにregionChangeFinishedをconnectして、Graphクラス内で最大値と最小値を取得する処理を実装しています。指定した範囲のXの最大値と最小値はlinear_region_item.getRegion()で取得できます。このXの最大値と最小値の範囲内に対応するYの値を取得するために、まずはXのDataSeriesとXの最大値、最小値をそれぞれ比較して、最大値と最小値の範囲内がTrueとなるBoolのDataSeriesを作成します。このBoolのDataSeriesをYのDataSeriesのインデックスとして指定することで、指定した範囲内のYのデータを取得しています。最後に取得したYのデータの最大値と最小値を取得することで、Xで範囲指定したYの最大値と最小値が取得できます。これらの値をQPlaneTextEditに表示してGUI上で確認できるようにしました。

    def regionChangeFinished(self, linear_region_item):
        yKey = self.comboBox.currentText()
        for key in self.linearRegionItem:
            item = self.linearRegionItem[key]
            if not item == linear_region_item:
                continue
            x_min, x_max = linear_region_item.getRegion()
            lower_flags, upper_flags = x_min < self.df[self.xKey], self.df[self.xKey] < x_max
            df_extract = self.df[lower_flags & upper_flags]
            self.y_min, self.y_max = df_extract[yKey].min(), df_extract[yKey].max()
        self.updatePlaneTextEdit()

グラフに表示するY軸データの種類の指定

Graphクラスには自作のCheckListクラスをセットしています。このCheckListクラスはCheckBoxのリストを表示したQListViewを持つクラスです。このCheckBoxのチェックが変化するとmodel.dataChangedが発行されて、GraphのcheckListModelDataChangedが実行され、CheckBoxのチェックの状態によってplotItemの表示を切り替えるという処理を行っています。この処理でチェックリストにチェックを入れるとグラフが表示されるという処理を実現しています。

class Graph(QtWidgets.QWidget):
    def __init__(self, parent=None, title=None):
        super(Graph, self).__init__(parent)
        self.plotItem = {}
        self.checkList = CheckList(self.groupBox)
        self.checkList.model.dataChanged.connect(self.checkListModelDataChanged)
        
    def checkListModelDataChanged(self, topLeftIndex, bottomRightIndex, roles):
        for item in self.checkList.items():
            self.plotItem.get( item.text() ).setVisible( item.checkState() == QtCore.Qt.Checked )
        
class CheckList(QtWidgets.QWidget):
    def __init__(self, parent=None, items=[]):
        super(CheckList, self).__init__(parent)
        self.model = QtGui.QStandardItemModel()
        self.layout().addWidget( QtWidgets.QListView(self) )

ソースコード

# -*- coding: utf-8 -*-
import pyqtgraph as pg
import pandas as pd
import sys
from pathlib import Path
from PyQt5 import QtWidgets, QtCore, QtGui

csv_encoding = 'utf-8'

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, parent=None):
        super(MainWindow, self).__init__(parent)
        self.resize(800, 600)

        self.graphList = GraphList(self)
        self.fileList = FileList()
        self.graphHeightSlider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
        self.graphHeightSlider.setRange(120, 600)
        self.xAxisValues = QtWidgets.QComboBox()
        self.yValueCheckList = CheckList()

        self.setCentralWidget(self.graphList)

        fileListDock = QtWidgets.QDockWidget('File list', self)
        fileListDock.setWidget(self.fileList)

        graphSettingsDock = QtWidgets.QDockWidget('Graph settings', self)
        graphSettingsDock.setWidget( QtWidgets.QWidget(graphSettingsDock) )

        graphSettingsLayout = QtWidgets.QGridLayout( graphSettingsDock.widget() )
        graphSettingsLayout.setContentsMargins(0, 0, 0, 0)
        graphSettingsLayout.addWidget( QtWidgets.QLabel('Height', graphSettingsDock.widget()), 0, 0, 1, 1 )
        graphSettingsLayout.addWidget( QtWidgets.QLabel('X axis', graphSettingsDock.widget()), 1, 0, 1, 1 )
        graphSettingsLayout.addWidget( QtWidgets.QLabel('Y axis', graphSettingsDock.widget()), 2, 0, 1, 1 )
        graphSettingsLayout.addWidget( self.graphHeightSlider, 0, 1, 1, 1 )
        graphSettingsLayout.addWidget( self.xAxisValues,       1, 1, 1, 1 )
        graphSettingsLayout.addWidget( self.yValueCheckList,   3, 0, 1, 2 )

        self.graphHeightSlider.setParent( graphSettingsDock.widget() )
        self.xAxisValues.setParent( graphSettingsDock.widget() )
        self.yValueCheckList.setParent( graphSettingsDock.widget() )

        self.addDockWidget(QtCore.Qt.DockWidgetArea(1), fileListDock)
        self.addDockWidget(QtCore.Qt.DockWidgetArea(1), graphSettingsDock)

        self.menuBar().addAction( fileListDock.toggleViewAction() )
        self.menuBar().addAction( graphSettingsDock.toggleViewAction() )

        self.graphHeightSlider.valueChanged.connect(self.sliderChanged)
        self.fileList.directoryChanged.connect(self.directoryChanged)
        self.fileList.checkListChanged.connect(self.fileListStateChanged)
        self.xAxisValues.currentTextChanged.connect(self.xAxisValuesChanged)
        self.yValueCheckList.model.dataChanged.connect(self.checkListModelDataChanged)

    def checkListModelDataChanged(self, topLeftIndex, bottomRightIndex, roles):
        key_and_state_dict = {}
        for item in self.yValueCheckList.items():
            key_and_state_dict[item.text()] = item.checkState()
        self.graphList.setCheckStates(key_and_state_dict)

    def directoryChanged(self):
        filenames = [ path.name for path in self.fileList.paths() ]
        graph_titles = self.graphList.titles()

        for graph_title in graph_titles:
            if not graph_title in filenames:
                self.graphList.deleteGraph(graph_title)

        graph_titles = self.graphList.titles()
        xKey = self.xAxisValues.currentText()
        checkItems = []

        for path in self.fileList.paths():
            if path.name in graph_titles:
                graph = self.graphList.getGraph(path.name)
            else:
                graph = self.graphList.addGraph(path, path.name, xKey)
            graph.setHeight( self.graphHeightSlider.value() )
            for item in graph.checkList.items():
                if not item.text() in checkItems:
                    checkItems.append( item.text() )
        
        self.yValueCheckList.setItems(checkItems, QtCore.Qt.Unchecked)
        self.xAxisValues.clear()
        self.xAxisValues.addItems(checkItems)

    def fileListStateChanged(self, itemText, state):
        self.graphList.changeGraphVisible(itemText, state == QtCore.Qt.Checked)
    
    def sliderChanged(self, value):
        for graph in self.graphList.graphs():
            graph.setHeight(value)

    def xAxisValuesChanged(self, text):
        for graph in self.graphList.graphs():
            graph.changeXAxis(text)

class GraphList(QtWidgets.QWidget):
    def __init__(self, parent=None):
        super(GraphList, self).__init__(parent)
        layout = QtWidgets.QVBoxLayout(self)
        scrollArea = QtWidgets.QScrollArea(self)
        spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
        self.scrollWidget = QtWidgets.QWidget(self)
        self.scrollLayout = QtWidgets.QVBoxLayout(self.scrollWidget)

        layout.setContentsMargins(0, 0, 0, 0)
        scrollArea.setWidgetResizable(True)
        scrollArea.setWidget(self.scrollWidget)
        layout.addWidget(scrollArea)
        self.scrollLayout.addItem(spacerItem)
    
    def addGraph(self, csv_path, title, xKey):
        graph = Graph(self.scrollWidget, title)
        graph.setDataFrame(pd.read_csv(csv_path, encoding=csv_encoding), xKey)
        self.scrollLayout.insertWidget(self.scrollLayout.count() - 1, graph)
        return graph

    def deleteGraph(self, title):
        if not self.getGraph(title) is None:
            self.getGraph(title).deleteLater()

    def getGraph(self, title):
        for graph in self.graphs():
            if title == graph.title():
                return graph
        return None

    def graphs(self):
        return [ 
            self.scrollLayout.itemAt(c).widget()
            for c in range( self.scrollLayout.count() )
            if type(self.scrollLayout.itemAt(c).widget()) is Graph
        ]

    def changeGraphVisible(self, title, isVisivle):
        graph = self.getGraph(title)
        if not graph is None:
            graph.setVisible(isVisivle)
        
    def setCheckStates(self, key_and_state_dict):
        for graph in self.graphs():
            graph.setCheckStates(key_and_state_dict)

    def titles(self):
        return [ graph.groupBox.title() for graph in self.graphs() ]

class FileList(QtWidgets.QWidget):
    directoryChanged = QtCore.pyqtSignal()
    checkListChanged = QtCore.pyqtSignal(str, int)
    def __init__(self, parent=None, path=None):
        super(FileList, self).__init__(parent)
        self.setLayout( QtWidgets.QVBoxLayout(self) )
        self.layout().setContentsMargins(0, 0, 0, 0)
        widget = QtWidgets.QWidget(self)
        self.layout().addWidget(widget)
        self.checkList = CheckList(self)
        self.layout().addWidget(self.checkList)
        layout = QtWidgets.QHBoxLayout(widget)
        widget.setLayout(layout)
        layout.setContentsMargins(0, 0, 0, 0)
        button = QtWidgets.QPushButton('Open', widget)
        layout.addWidget(button)
        self.lineEdit =  QtWidgets.QLineEdit(widget)
        layout.addWidget(self.lineEdit)

        self.fileSystemWatcher = QtCore.QFileSystemWatcher(self)
        self.lineEdit.textChanged.connect(self.lineEditChanged)
        self.fileSystemWatcher.directoryChanged.connect(self.directoryChangedSlot)
        self.checkList.model.dataChanged.connect(self.checkListModelDataChanged)
        button.clicked.connect(self.folderOpen)

        if not path is None:
            self.lineEditChanged(path)
            self.lineEdit.setText(path)

    def checkListModelDataChanged(self, topLeftIndex, bottomRightIndex, roles):
        model, row = topLeftIndex.model(), topLeftIndex.row()
        filename = Path( model.item(row).text() ).name
        self.checkListChanged.emit( filename, model.item(row).checkState() )

    def directoryChangedSlot(self, string):
        self.checkList.setItems( Path(string).glob('*.csv'), QtCore.Qt.Checked )
        self.directoryChanged.emit()

    def folderOpen(self):
        return_value = QtWidgets.QFileDialog.getExistingDirectory(None, 'Open folder', '')
        if not return_value == '':
            self.lineEdit.setText(return_value)

    def lineEditChanged(self, text):
        path = Path(text)
        for d in self.fileSystemWatcher.directories():
            self.fileSystemWatcher.removePath(d)
        if not path.exists():
            return
        self.checkList.setItems( path.glob('*.csv'), QtCore.Qt.Checked )
        self.fileSystemWatcher.addPath(text)
        self.directoryChanged.emit()

    def paths(self):
        return [ Path( item.text() ) for item in self.checkList.items() ]

class Graph(QtWidgets.QWidget):
    def __init__(self, parent=None, title=None):
        super(Graph, self).__init__(parent)
        self.df, self.y_min, self.y_max = None, 0, 0
        self.linearRegionItem = {}
        self.plotItem = {}
        self.setLayout( QtWidgets.QVBoxLayout(self) )

        self.groupBox = QtWidgets.QGroupBox(title, self)
        self.layout().addWidget( self.groupBox )
        self.layout().setContentsMargins(0, 0, 0, 0)

        groupBoxLayout = QtWidgets.QGridLayout(self.groupBox)
        groupBoxLayout.setContentsMargins(2, 2, 2, 2)
        self.groupBox.setLayout( groupBoxLayout )

        self.plotWidget = pg.PlotWidget(self.groupBox)
        self.plotWidget.setBackground('#FFFFFFFF')
        self.plotWidget.plotItem.getAxis('bottom').setPen(pg.mkPen(color='#000000'))
        self.plotWidget.plotItem.getAxis('left').setPen(pg.mkPen(color='#000000'))
        self.plotWidget.plotItem.showGrid(True, True, 0.3)

        self.comboBox = QtWidgets.QComboBox(self.groupBox)
        self.comboBox.addItem('None')
        
        self.planeTextEdit = QtWidgets.QPlainTextEdit(self.groupBox)
        self.planeTextEdit.setMaximumWidth(120)

        self.checkList = CheckList(self.groupBox)
        self.checkList.setMaximumWidth(160)

        groupBoxLayout.addWidget(self.plotWidget, 0, 0, 2, 1)
        groupBoxLayout.addWidget(self.comboBox, 0, 1, 1, 1)
        groupBoxLayout.addWidget(self.planeTextEdit, 1, 1, 1, 1)
        groupBoxLayout.addWidget(self.checkList, 0, 2, 2, 1)
        
        self.comboBox.currentTextChanged.connect(self.comboBoxCurrentTextChanged)
        self.checkList.model.dataChanged.connect(self.checkListModelDataChanged)
        
    def changeXAxis(self, xKey=''):
        self.linearRegionItem = {}
        self.plotItem = {}
        self.plotWidget.plotItem.clear()
        
        self.xKey = xKey
        if xKey == '':
            self.xKey = self.df.columns[0]

        for column in self.df.columns:
            if self.df[column].dtype.kind == 'O':
                continue

            if not self.xKey in self.df.columns:
                continue

            self.plotItem[column] = pg.PlotDataItem(
                x=self.df[self.xKey], y=self.df[column], pen=pg.mkPen(color='#000000', width=1), antialias=True
            )
            self.plotWidget.addItem( self.plotItem[column] )
            self.plotItem[column].hide()

            linear_region_item = pg.LinearRegionItem(( self.df[self.xKey].min(), self.df[self.xKey].max() ), 'vertical')
            self.plotWidget.addItem(linear_region_item)
            linear_region_item.sigRegionChangeFinished.connect(self.regionChangeFinished)
            self.linearRegionItem[column] = linear_region_item
            self.linearRegionItem[column].hide()

            self.comboBox.addItem(column)
        
        self.checkListModelDataChanged(None, None, None)

    def checkListModelDataChanged(self, topLeftIndex, bottomRightIndex, roles):
        for item in self.checkList.items():
            plotItem = self.plotItem.get( item.text() )
            if plotItem is None:
                continue
            plotItem.setVisible( item.checkState() == QtCore.Qt.Checked )
            
        viewBox = [ item for item in self.plotWidget.items() if type(item) is pg.graphicsItems.ViewBox.ViewBox ]
        if len(viewBox) > 0:
            viewBox[0].updateAutoRange()

    def comboBoxCurrentTextChanged(self, text):
        for key in self.linearRegionItem:
            self.linearRegionItem[key].setVisible(key == text)
            
        if text == 'None':
            self.planeTextEdit.setPlainText('')
            return

        self.updatePlaneTextEdit()

    def regionChangeFinished(self, linear_region_item):
        yKey = self.comboBox.currentText()
        for key in self.linearRegionItem:
            item = self.linearRegionItem[key]
            if not item == linear_region_item:
                continue
            x_min, x_max = linear_region_item.getRegion()
            lower_flags, upper_flags = x_min < self.df[self.xKey], self.df[self.xKey] < x_max
            df_extract = self.df[lower_flags & upper_flags]
            self.y_min, self.y_max = df_extract[yKey].min(), df_extract[yKey].max()
        self.updatePlaneTextEdit()

    def setCheckStates(self, key_and_state_dict):
        for key in key_and_state_dict:
            item_texts = [ item.text() for item in self.checkList.items() ]
            if not key in item_texts:
                continue
            row = item_texts.index(key)
            self.checkList.setCheckState(row, key_and_state_dict[key])

    def setDataFrame(self, df, xKey):
        self.df, self.xKey = df, xKey
        self.changeXAxis(self.xKey)
        self.checkList.setItems(list(self.df.columns), QtCore.Qt.Unchecked)
        self.comboBoxCurrentTextChanged( self.comboBox.currentText() )

    def setHeight(self, value):
        self.groupBox.setMinimumHeight(value)
        self.groupBox.setMaximumHeight(value)

    def title(self):
        return self.groupBox.title()

    def updatePlaneTextEdit(self):
        key = self.comboBox.currentText()
        x_min, x_max = self.linearRegionItem[key].getRegion()
        df_extract = self.df[ (x_min < self.df[self.xKey]) & (self.df[self.xKey] < x_max) ]
        self.y_min, self.y_max = df_extract[key].min(), df_extract[key].max()
        plainText  = key + '\n'
        plainText += 'max : ' + str( round(self.y_max, 9) ) + '\n'
        plainText += 'min : ' + str( round(self.y_min, 9) )
        self.planeTextEdit.setPlainText(plainText)

class CheckList(QtWidgets.QWidget):
    def __init__(self, parent=None, items=[]):
        super(CheckList, self).__init__(parent)
        self.model = QtGui.QStandardItemModel()
        self.setLayout( QtWidgets.QVBoxLayout(self) )
        self.layout().setContentsMargins(0, 0, 0, 0)
        self.layout().addWidget( QtWidgets.QListView(self) )
        self.layout().itemAt(0).widget().setModel(self.model)
        self.setItems(items, QtCore.Qt.Unchecked)

    def item(self, row, column=0):
        return self.model.item(row, column)

    def items(self):
        return [ self.model.item(r) for r in range( self.model.rowCount() ) ]

    def setCheckState(self, row, state):
        self.model.item(row).setCheckState(state)

    def setItems(self, string_list, state):
        self.model.clear()
        for string in string_list:
            item = QtGui.QStandardItem( str(string) )
            item.setCheckable(True)
            item.setCheckState(state)
            self.model.appendRow(item)

def main():
    app = QtWidgets.QApplication(sys.argv)
    window = MainWindow()
    window.show()
    app.exec()

if __name__ == '__main__':
    main()

記事の共有

関連記事

コメント

comments powered by Disqus