一、项目介绍

本项目将通过PySide6构建一个可以显示数据折线图的可视化程序,其中,数据来源时美国地质调查局(US Geological Survey)上公开的一小时地震震级数据。

可以通过链接进行下载。


二、实现步骤

本项目的实现步骤可以概括为:

  • 读取数据
  • 数据处理
  • 创建主窗口
  • 添加控件
  • 绘制图形并显示、

预期结果如下图所示:

image-20221223202408064

三、实现

1️⃣ 读取数据

这里我们借助Pandas对CSV文件进行读取。argparse模块主要用于参数控制,具体可见这篇文章

创建一个新文档main.py,接下来都是一些简单的操作,就不做赘述了。

1
2
3
4
5
6
7
8
9
10
11
12
import argparse
import pandas as pd

def read_data(file):
return pd.read_csv(file)

if __name__ == '__main__':
options=argparse.ArgumentParser()
options.add_argument("-f","--file",type=str,required=True)
args=options.parse_args()
data=read_data(args.file)
print(data)

我们可以通过终端输入python main.py -f "YourPath"来查看数据读取情况。


2️⃣ 数据清洗

我们在这部,需要将数据中的日期转换为Qt类型,并且确保数据的完整性、准确性。

值得注意的是,数据中的日期是UTC标准(如: 2018-12-11T21:14:44,682Z),我们可以比较容易地转换为QDateTime类型。

这个QtDateTime位于QtCore模块,从QtCore中将其导入:

1
from PySide6.QtCore import QDateTime,QTimeZone

接着,通过QtDateTime().fromString( time , format )进行转换:

1
2
3
4
5
6
7
def transform_date(utc,timezone=None):
# 日期转换
utc_fmt="yyyy-MM-ddTHH:mm:ss.zzzZ"
new_date=QDateTime().fromString(utc,utc_fmt)
if timezone:
new_date.setTimeZone(timezone)
return new_date

我们对读取数据方法进行一定的修改,首先是移除错误的震级数据:

1
2
3
data=pd.read_csv(file)
data=data.drop(data[data['mag']<0].index)
magnitudes=data['mag']

然后,设置本地的时区:

1
2
3
4
5
# 时区设置
timezone = QTimeZone(QTimeZone.systemTimeZone()) # "Asia/Shanghai"
# 时间转换
times=data['time'].apply(lambda x:transform_date(x,timezone))
return times,magnitudes

虽然不该在这篇文档中提,但还是提一嘴:

Pandas快速对某一类操作:

1
Series.apply(lambda x:func(x)) # apply(func)但是如果要输入参数,可以这样写: .apply(lambda x:func(x))

好了,此时我们的read_data方法应该长这样:

1
2
3
4
5
6
7
8
9
10
11
def read_data(file):
# 读取数据
data=pd.read_csv(file)
# 处理震级
data=data.drop(data[data['mag']<0].index)
magnitudes=data['mag']
# 时区设置
timezone = QTimeZone(QTimeZone.systemTimeZone()) # "Asia/Shanghai"
# 时间转换
times=data['time'].apply(lambda x:transform_date(x,timezone))
return times,magnitudes

3️⃣ 创建主窗体

好啦,终于要进入我们的核心啦,这一步我们将创建一个PySide主窗口。

下图是QMainWindow的布局。

image-20221223210213019

在本项目中,我们需要一个“文件”菜单,用来打开文件对话框,和一个“退出”菜单。应用程序启动时,应该要自动加载状态栏。

我们新建一个文件,叫做MainWindow.py

1
2
3
4
5
6
7
8
from PySide6.QtCore import Slot
from PySide6.QtGui import QAction,QKeySequence
from PySide6.QtWidgets import QMainWindow

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

让我们的窗体控件继承自QMainWindow。

然后是菜单栏的设置,直接选择获取self.menuBar()即可,通过addMenu(Str)的方式,可以添加选项。

1
2
3
4
# 设置菜单栏
self.menu=self.menuBar()
# 添加 文件 菜单项
self.file_menu=self.menu.addMenu("文件")

然后我们为菜单栏添加一个退出事件。这个事件可以通过QAction来直接绑定。QAction("Exit",self)表示退出本窗体。

1
2
3
4
5
6
# 退出事件
exit_action=QAction("Exit",self) # 设置名字
exit_action.setShortcut(QKeySequence.Quit) # 设置快捷键
exit_action.triggered.connect(self.close) # 该事件直接与close函数关联

self.file_menu.addAction(exit_action)

再添加状态栏

1
2
3
# 状态栏
self.status=self.statusBar()
self.status.showMessage("Data loaded and plotted")

以及设置窗口尺寸,直接通过self.screen().availableGeometry()方法获取当前可用的窗口大小,我们将主窗体的大小设置为可用窗口大小的(0.8,0.7)。

1
2
3
# 窗口尺寸
geometry=self.screen().availableGeometry()
self.setFixedSize(geometry.width()*0.8,geometry.height()*0.7)

4️⃣ 添加控件

现在,我们需要添加一个表视图,用来显示数据。

我们可以建一个QTableView对象,并将其放置在QHBoxLayout中,并将其作为小部件传递给我们的主窗体。

值得注意的是,QTableView需要一个模型来显示信息。在这种情况下,可以使用QAbstractTableModel实例。

要子类化QAbstractTable,必须重新实现它的抽象方法rowCount()、columnCount()和data()。通过这种方式,可以确保正确地处理数据。此外,重新实现headerData()方法以向视图提供头部信息。

我们再新建一个文件,就叫做TableModel.py好了。

这里我们实现了三个抽象方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
from PySide6.QtCore import Qt,QAbstractTableModel,QModelIndex
from PySide6.QtGui import QColor

class CustomTableModel(QAbstractTableModel):
def __init__(self,data=None):
super(CustomTableModel, self).__init__()
self.load_data(data)

def load_data(self,data):
# 获取UTC日期
self.input_dates=data[0].values
# 获取震级
self.input_magnitudes=data[1].values

self.column_count=2
self.row_count=len(self.input_dates)

def rowCount(self, parent=QModelIndex()):
# parent需要获取Qt模型索引
return self.row_count
def columnCount(self, parent=QModelIndex()):
# parent需要获取Qt模型索引
return self.column_count
def headerData(self,section,orientation,role):
# 头文件信息
if role!=Qt.DisplayRole:
return None
if orientation==Qt.Horizontal:
return ("Date","Magnitude")[section]
else:
return f"{section}"

def data(self,index,role=Qt.DisplayRole):
# 需要实现的抽象方法
# index是索引位置,role是当前状态

column=index.column()
row=index.row()

if role==Qt.DisplayRole:
# 如果当前在显示
if column==0:
# 表示当前的位置为: row,0 理应返回date数据
# 返回str格式的数据,除了时区
date=self.input_dates[row].toPython()
return str(date)[:-3]
elif column==1:
# 返回震级数据
magnitude=self.input_magnitudes[row]
return f"{magnitude:.2f}"

elif role==Qt.BackgroundRole:
# 在背后的话就返回一个颜色
return QColor(Qt.white)
elif role==Qt.TextAlignmentRole:
return Qt.AlignRight
return None

接着就可以再构建我们自己的小控件啦,新建一个文件,叫做TableWidge,将我们的模型导入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from PySide6.QtWidgets import (QHBoxLayout,QHeaderView,QSizePolicy,QTableView,QWidget)
from Q003_TableModel import CustomTableModel # 我这里是写作Q003,去掉就行,换成上一个文件的名字

class Widget(QWidget):
def __init__(self,data):
super(Widget, self).__init__()

# 获取TableModel
self.model=CustomTableModel(data) # 基于TableModel的数据读取

# 创建表视图
self.table_view=QTableView()
self.table_view.setModel(self.model) # 设置模型

# 设置表视图的标头
self.horizontal_header=self.table_view.horizontalHeader()
self.vertical_header=self.table_view.verticalHeader()
self.horizontal_header.setSectionResizeMode(
QHeaderView.ResizeToContents
)# 依据内容自动设置尺寸
self.vertical_header.setSectionResizeMode(
QHeaderView.ResizeToContents
)
self.horizontal_header.setStretchLastSection(True) # 拉伸部件

# 设置布局
self.main_layout=QHBoxLayout()
size=QSizePolicy(QSizePolicy.Preferred,QSizePolicy.Preferred) # 默认sizeHint()为最优尺寸的策略

# 水平布局
size.setHorizontalStretch(1) # 设置水平拉伸因子为1
self.table_view.setSizePolicy(size) # 对组件应用尺寸策略
self.main_layout.addWidget(self.table_view) # mainlayout是一个水平布局盒子,将我们的组件加进来

# 将布局设置到QWidget中
self.setLayout(self.main_layout) # 最后,将我们的水平盒子设为小组件的布局

QSizePolicy 类是布局属性,描述了水平和垂直大小调整策略,部分参数如下:

参数名 作用
Fixed size固定为Qwidget.sizeHint()
Minimum size不能小于sizeHint()的大小
Maximum size不能大于sizeHint()的大小
Preferred 最佳size为sizeHint()
Expanding sizeHint是推荐的size,但尽可能地获取更大的空间
Ignored sizeHint()被忽略,小部件将尽可能获取空间

这里我们用到了void setHorizontalStretch\setVerticalStretch (int stretchFactor)方法,这个方法是用来设置大小策略的水平/垂直拉伸因子的,范围必须在[0,255]。举个栗子,当有两个部件水平相邻时,左边的部件拉伸系数为2,右边的拉深系数为1,那么将确保左边窗口的大小始终是右边的两倍。

好了,现在我们有一个TableView组件啦,将其加入主窗口。在MainWindow.py文件中添加如下代码:

1
2
3
4
class MainWindow(QMainWindow):
def __init__(self,widget):
super(MainWindow, self).__init__()
self.setCentralWidget(widget)

main.py中添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from resource.OtherSupportPyFile.Q003_DataProcess import MainWindow
# 注意写成自己的路径和文件名
from resource.OtherSupportPyFile.Q003_Table import Widget
# 注意写成自己的路径和文件名
import sys
from PySide6.QtWidgets import QApplication


if __name__ == '__main__':
options=argparse.ArgumentParser()
options.add_argument("-f","--file",type=str,required=True)
args=options.parse_args()
data=read_data(args.file)

# 创建应用程序
app=QApplication()
widget=Widget(data)
window=MainWindow(widget)
window.show()

sys.exit(app.exec())

好了,最后的结果如下所示:

image-20221223221445259

5️⃣ 添加并绘制图

有了表后,就要添加图了!我们在之前的TableWidge.py上修改,基于Pyside6.QCharts绘制图形。

首先是导入模块:

1
2
3
4
5
from PySide6.QtWidgets import (QHBoxLayout,QHeaderView,QSizePolicy,QTableView,QWidget)
from .Q003_TableModel import CustomTableModel
from PySide6.QtCharts import QChart,QChartView,QLineSeries,QDateTimeAxis,QValueAxis
from PySide6.QtGui import QPainter
from PySide6.QtCore import QDateTime,Qt

添加创建图的方法

1
2
3
4
5
6
7
# 创建QChart对象
self.chat=QChart()
self.chat.setAnimationOptions(QChart.AllAnimations) # 设置移动动画

# 创建表视图
self.chat_view=QChartView(self.chat)
self.chat_view.setRenderHint(QPainter.Antialiasing) # 抗锯齿

将图设置为右边,且大小是表的四倍

1
2
3
4
# 右布局
size.setHorizontalStretch(4)
self.chat_view.setSizePolicy(size)
self.main_layout.addWidget(self.chat_view)

我们创建一个添加序列的方法,用来为Chart读取数据~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def add_series(self,name):
# 创建线序列QLineSeries
self.series=QLineSeries()
self.series.setName(name)

# 填充QLineSeries
for i in range(self.model.row_count):

t=self.model.index(i,0).data()
data_fmt="yyyy-MM-dd HH:mm:ss.zzz"
x=QDateTime().fromString(t,data_fmt).toSecsSinceEpoch() # 转化为时间戳
y=float(self.model.index(i,1).data())
if x>0 and y>0:
self.series.append(x,y)

self.chat.addSeries(self.series)

# 设置图样式
# 设置x坐标
self.axis_x=QDateTimeAxis()
self.axis_x.setTickCount(10) # 设置间隔
# self.axis_x.setFormat("dd.MM (h:mm)") # 设置时间显示
self.axis_x.setFormat("MM.dd") # 设置时间显示
self.axis_x.setTitleText("Date")
self.chat.addAxis(self.axis_x,Qt.AlignBottom) # 在表格中加入坐标,位置为底部
self.series.attachAxis(self.axis_x) # 自动让QLineSeries贴附

# 设置y坐标
self.axis_y=QValueAxis()
self.axis_y.setTickCount(10)
self.axis_y.setLabelFormat("%.2f")
self.axis_y.setTitleText("Magnitude")
self.chat.addAxis(self.axis_y,Qt.AlignLeft)
self.series.attachAxis(self.axis_y)

# 从Chart上获取颜色,并在QTableView上使用
color_name=self.series.pen().color().name()
self.model.color=f"{color_name}"

将其在初始化方法中绑定:

1
self.add_series("Magnitude")

最终的结果如下!

image-20221223224746459

完整代码附上:

1️⃣Q003_TableModel.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
from PySide6.QtCore import Qt,QAbstractTableModel,QModelIndex
from PySide6.QtGui import QColor

class CustomTableModel(QAbstractTableModel):
def __init__(self,data=None):
super(CustomTableModel, self).__init__()
self.load_data(data)

def load_data(self,data):
# 获取UTC日期
self.input_dates=data[0].values
# 获取震级
self.input_magnitudes=data[1].values

self.column_count=2
self.row_count=len(self.input_dates)

def rowCount(self, parent=QModelIndex()):
# parent需要获取Qt模型索引
return self.row_count
def columnCount(self, parent=QModelIndex()):
# parent需要获取Qt模型索引
return self.column_count
def headerData(self,section,orientation,role):
# 头文件信息
if role!=Qt.DisplayRole:
return None
if orientation==Qt.Horizontal:
return ("Date","Magnitude")[section]
else:
return f"{section}"

def data(self,index,role=Qt.DisplayRole):
# 需要实现的抽象方法
# index是索引位置,role是当前状态

column=index.column()
row=index.row()

if role==Qt.DisplayRole:
# 如果当前在显示
if column==0:
# 表示当前的位置为: row,0 理应返回date数据
# 返回str格式的数据,除了时区
date=self.input_dates[row].toPython()
return str(date)[:-3]
elif column==1:
# 返回震级数据
magnitude=self.input_magnitudes[row]
return f"{magnitude:.2f}"

elif role==Qt.BackgroundRole:
# 在背后的话就返回一个颜色
return QColor(Qt.white)
elif role==Qt.TextAlignmentRole:
return Qt.AlignRight
return None

2️⃣ Q003_DataProcess.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from PySide6.QtGui import QAction,QKeySequence
from PySide6.QtWidgets import QMainWindow

class MainWindow(QMainWindow):
def __init__(self,widget):
super(MainWindow, self).__init__()
self.setWindowTitle("Earquakes Information")
self.setCentralWidget(widget)

# 设置菜单栏
self.menu=self.menuBar()
# 添加 文件 菜单项
self.file_menu=self.menu.addMenu("文件")

# 退出事件
exit_action = QAction("Exit", self) # 设置名字
exit_action.setShortcut(QKeySequence.Quit) # 设置快捷键
exit_action.triggered.connect(self.close) # 该事件直接与close函数关联

self.file_menu.addAction(exit_action)

# 状态栏
self.status=self.statusBar()
self.status.showMessage("Data loaded and plotted")

# 窗口尺寸
geometry=self.screen().availableGeometry()
self.setFixedSize(geometry.width()*0.8,geometry.height()*0.7)

3️⃣ Q003_Table.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
from PySide6.QtWidgets import (QHBoxLayout,QHeaderView,QSizePolicy,QTableView,QWidget)
from .Q003_TableModel import CustomTableModel
from PySide6.QtCharts import QChart,QChartView,QLineSeries,QDateTimeAxis,QValueAxis
from PySide6.QtGui import QPainter
from PySide6.QtCore import QDateTime,Qt


class Widget(QWidget):
def __init__(self,data):
super(Widget, self).__init__()

# 获取TableModel
self.model=CustomTableModel(data) # 基于TableModel的数据读取

# 创建表视图
self.table_view=QTableView()
self.table_view.setModel(self.model) # 设置模型

# 设置表视图的标头
self.horizontal_header=self.table_view.horizontalHeader()
self.vertical_header=self.table_view.verticalHeader()
self.horizontal_header.setSectionResizeMode(
QHeaderView.ResizeToContents
)# 依据内容自动设置尺寸
self.vertical_header.setSectionResizeMode(
QHeaderView.ResizeToContents
)
self.horizontal_header.setStretchLastSection(True) # 拉伸部件

# 创建QChart对象
self.chat=QChart()
self.chat.setAnimationOptions(QChart.AllAnimations) # 设置移动动画
self.add_series("Magnitude")

# 创建表视图
self.chat_view=QChartView(self.chat)
self.chat_view.setRenderHint(QPainter.Antialiasing) # 抗锯齿


# 设置布局
self.main_layout=QHBoxLayout()
size=QSizePolicy(QSizePolicy.Preferred,QSizePolicy.Preferred) # 默认sizeHint()为最优尺寸的策略

# 左布局
size.setHorizontalStretch(1) # 设置水平拉伸因子为1
self.table_view.setSizePolicy(size) # 对组件应用尺寸策略
self.main_layout.addWidget(self.table_view) # mainlayout是一个水平布局盒子,将我们的组件加进来

# 右布局
size.setHorizontalStretch(4)
self.chat_view.setSizePolicy(size)
self.main_layout.addWidget(self.chat_view)

# 将布局设置到QWidget中
self.setLayout(self.main_layout) # 最后,将我们的水平盒子设为小组件的布局

def add_series(self,name):
# 创建线序列QLineSeries
self.series=QLineSeries()
self.series.setName(name)

# 填充QLineSeries
for i in range(self.model.row_count):

t=self.model.index(i,0).data()
data_fmt="yyyy-MM-dd HH:mm:ss.zzz"
x=QDateTime().fromString(t,data_fmt).toSecsSinceEpoch() # 转化为时间戳
y=float(self.model.index(i,1).data())
if x>0 and y>0:
self.series.append(x,y)

self.chat.addSeries(self.series)

# 设置图样式
# 设置x坐标
self.axis_x=QDateTimeAxis()
self.axis_x.setTickCount(10) # 设置间隔
# self.axis_x.setFormat("dd.MM (h:mm)") # 设置时间显示
self.axis_x.setFormat("MM.dd") # 设置时间显示
self.axis_x.setTitleText("Date")
self.chat.addAxis(self.axis_x,Qt.AlignBottom) # 在表格中加入坐标,位置为底部
self.series.attachAxis(self.axis_x) # 自动让QLineSeries贴附

# 设置y坐标
self.axis_y=QValueAxis()
self.axis_y.setTickCount(10)
self.axis_y.setLabelFormat("%.2f")
self.axis_y.setTitleText("Magnitude")
self.chat.addAxis(self.axis_y,Qt.AlignLeft)
self.series.attachAxis(self.axis_y)

# 从Chart上获取颜色,并在QTableView上使用
color_name=self.series.pen().color().name()
self.model.color=f"{color_name}"

4️⃣ Main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import argparse
import pandas as pd
from PySide6.QtCore import QDateTime,QTimeZone
from PySide6.QtWidgets import QApplication
from resource.OtherSupportPyFile.Q003_DataProcess import MainWindow
from resource.OtherSupportPyFile.Q003_Table import Widget
import sys


def transform_date(utc,timezone=None):
# 日期转换
utc_fmt="yyyy-MM-ddTHH:mm:ss.zzzZ"
new_date=QDateTime().fromString(utc,utc_fmt)
if timezone:
new_date.setTimeZone(timezone)
return new_date

def read_data(file):
# 读取数据
data=pd.read_csv(file)
# 处理震级
data=data.drop(data[data['mag']<0].index)
magnitudes=data['mag']
# 时区设置
timezone = QTimeZone(QTimeZone.systemTimeZone()) # "Asia/Shanghai"
# 时间转换
times=data['time'].apply(lambda x:transform_date(x,timezone))
return times,magnitudes


if __name__ == '__main__':
options=argparse.ArgumentParser()
options.add_argument("-f","--file",type=str,required=True)
args=options.parse_args()
data=read_data(args.file)
# 创建应用程序
app=QApplication()
widget=Widget(data)
window=MainWindow(widget)
window.show()

sys.exit(app.exec())