Python-开发者的-Matplotlib-第二版-二-

74 阅读1小时+

Python 开发者的 Matplotlib 第二版(二)

原文:annas-archive.org/md5/390191aec2993c8974efd865f6620359

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:在 GTK+3 中嵌入 Matplotlib

到目前为止,我们已经做了不少示例,并且已经打下了良好的基础,能够使用 Matplotlib 生成数据图表和图形。虽然单独使用 Matplotlib 在生成交互式图形、实验数据集和理解数据的子结构方面非常方便,但也可能出现需要一个应用程序来获取、解析并显示数据的情况。

本章将研究如何通过 GTK+3 将 Matplotlib 嵌入应用程序的示例。

安装和设置 GTK+3

设置 GTK+3 相对简单直观。根据操作系统版本和环境的不同,安装 GTK+3 有多种方式。

我们建议读者参考链接:python-gtk-3-tutorial.readthedocs.io/en/latest/install.html以获取最新的安装更新和信息。

在写这本书时,官方网站建议用户通过 JHBuild 安装 GTK+3。然而,用户发现 JHBuild 在 macOS El Capitan 上存在兼容性问题。

我们建议 macOS 用户使用包管理器brew来安装 GTK+3。

如果你的 macOS 已经安装了brew,则可以简单地安装 GTK+3:

#Installing the gtk3 package
brew install gtk3
#Installing PyGObject
brew install pygobject3

对于像 Ubuntu 这样的 Linux 系统,GTK+3 默认已经安装。对于那些喜欢更自定义安装方式的高级用户,我们建议访问官网获取最新的安装信息。

我们观察到 GTK+3 在 IPython Notebook 中的可视化兼容性不如预期。我们建议你在终端中运行代码以获得最佳效果。

GTK+3 简要介绍

在探索各种示例和应用之前,让我们先对 GTK+3 进行一个简要的高层次了解。

GTK+3 包含一组图形控件元素(小部件),是一个功能丰富、易于使用的工具包,用于开发图形用户界面。它具有跨平台兼容性,且相对容易使用。GTK+3 是一个面向对象的小部件工具包,用 C 语言编写。因此,当在 Python 中运行 GTK+3 时,我们需要一个包装器来调用 GTK+3 库中的函数。在这种情况下,PyGObject 是一个 Python 模块,它作为包装器为我们节省了不必学习两种语言来绘制图形的时间。PyGObject 专门支持 GTK+3 或更高版本。如果你更喜欢在应用程序中使用 GTK+2,我们建议使用 PyGTK。

与 Glade GUI 构建器一起,它们提供了一个非常强大的应用程序开发环境。

GTK+3 信号系统简介

GTK+3 是一个事件驱动的工具包,这意味着它始终在一个循环函数中处于休眠状态,等待(监听)事件的发生;然后它将控制权交给相应的函数。事件的例子有点击按钮、激活菜单项、勾选复选框等。当小部件接收到事件时,它们通常会发出一个或多个信号。这个信号将调用你连接的函数,在这种情况下称为回调函数。控制的传递是通过信号的概念来完成的。

尽管术语几乎相同,但 GTK+3 的信号与 Unix 系统信号不同,并且并非使用它们实现。

当像鼠标按钮按下这样的事件发生时,点击接收到小部件的控件会发出相应的信号。这是 GTK+3 工作原理中最重要的部分之一。有些信号是所有小部件都继承的,例如destroydelete-event,还有一些是特定于小部件的信号,例如切换按钮的切换。为了使信号框架生效,我们需要设置一个信号处理程序来捕获这些信号并调用相应的函数。

从更抽象的角度来看,一个通用的例子如下:

handler_id = widget.connect("Event", callback, data )

在这个通用示例中,widget是我们之前创建的小部件的一个实例。它可以显示小部件、按钮、切换按钮或文本数据输入。每个小部件都有自己的特定事件,只有当该事件发生时,它才会响应。如果小部件是按钮,当发生点击等动作时,信号将被发出。callback参数是回调函数的名称。当事件发生时,回调函数将被执行。最后,data参数包括任何在生成信号时需要传递的数据;这是可选的,如果回调函数不需要参数,可以省略。

这是我们第一个 GTK+3 示例:

#In here, we import the GTK module in order to access GTK+3's classes and functions
#We want to make sure we are importing GTK+3 and not any other version of the library
#Therefore we require_version('Gtk','3.0')
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk

#This line uses the GTK+3 functions and creates an empty window
window = Gtk.Window(title="Hello World!")
#We created a handler that connects window's delete event to ensure the application
#is terminated if we click on the close button
window.connect("destroy",Gtk.main_quit)
#Here we display the window
window.show_all()
#This tells the code to run the main loop until Gtk.main_quit is called
Gtk.main()

要运行此代码,读者可以选择复制并粘贴,或将代码保存到名为first_gtk_example.py的文件中,并在终端中运行,如下所示:

python3 first_gtk_example.py

读者应该能够创建一个空白的 200x200 像素窗口(默认情况下,如果没有指定其他内容),如下所示:

图 1

为了充分理解 GTK3+的实用性,建议将代码编写为 PyGObject。

以下代码演示了一个修改版的稍微复杂的示例,其中一个窗口中有两个点击按钮,每个按钮执行不同的任务!

读者在运行本章示例之前,应通过pip3安装cairocffi

pip3 install cairocffi

cairocffi库是一个基于 CFFI 的替代库,用于替代 Pycairo,在此案例中是必需的。现在让我们深入了解代码:

#Again, here we import the GTK module
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk

#From here, we define our own class, namely TwoClicks.
#This is a sub-class of Gtk.Window
class TwoClicks(Gtk.Window):

    #Instantiation operation will creates an empty object
    #Therefore, python3 uses __init__() to *construct* an object
    #__init__() will be automatically invoked when the object is being created!
    #You can call this the constructor in Python3
    #Noted that *self* here indicates the reference of the object created from this class
    #Anything starting with self.X refers to the local function or variables of the object itself!
    def __init__(self):

        #In here, we are essentially constructing a Gtk.Window object
        #And parsing the information title="Hello world" to the constructor of Gtk.Window
        #Therefore, the window will have a title of "Hello World"
        Gtk.Window.__init__(self, title="Hello World")

        #Since we have two click buttons, we created a horizontally oriented box container
        #with 20 pixels placed in between children - the two click buttons
        self.box = Gtk.Box(spacing=100)

        #This assigns the box to become the child of the top-level window
        self.add(self.box)

        #Here we create the first button - click1, with the title "Print once!" on top of it
        self.click1 = Gtk.Button(label="Print once!")

        #We assign a handler and connect the *Event* (clicked) with the *callback/function* (on_click1)
        #Noted that, we are now calling the function of the object itself
        #Therefore we are using *self.onclick1 
        self.click1.connect("clicked", self.on_click1)

        #Gtk.Box.pack_start() has a directionality here, it positions widgets from left to right!
        self.box.pack_start(self.click1, True, True, 0)

        #The same applies to click 2, except that we connect it with a different function
        #which prints Hello World 5 times!
        self.click2 = Gtk.Button(label="Print 5 times!")
        self.click2.connect("clicked", self.on_click2)
        self.box.pack_start(self.click2, True, True, 0)

    #Here defines a function on_click1 in the Class TwoClicks
    #This function will be triggered when the button "Print once!" is clicked
    def on_click1(self, widget):
        print("Hello World")

    #Here defines a function on_click2 in the Class TwoClicks
    #This function will be triggered when the button "Print 5 times!" is clicked
    def on_click2(self, widget):
        for i in range(0,5):
            print("Hello World")

#Here we instantiate an object, namely window
window = TwoClicks()
#Here we want the window to be close when the user click on the close button
window.connect("delete-event", Gtk.main_quit)
#Here we display the window!
window.show_all()
#This tells the code to run the main loop until Gtk.main_quit is called
Gtk.main()

以下是你从上面的代码片段中得到的结果:

图 2

点击不同的按钮将导致在终端上获得不同的结果。

这个示例作为面向对象编程OOP)风格的介绍。对于新手用户来说,OOP 可能有些复杂,但它是组织代码、创建模块以及增强代码可读性和可用性的最佳方式之一。虽然新手用户可能没有注意到,但在前四章中,我们已经使用了许多 OOP 概念。

通过理解init()self,我们现在可以深入研究更高级的编程技巧了。那么,让我们尝试一些更高级的例子!如果我们想要将我们制作的一些图表嵌入到 GTK+3 窗口中,我们可以这样做:

#Same old, importing Gtk module, we are also importing some other stuff this time
#such as numpy and the backends of matplotlib
import gi, numpy as np, matplotlib.cm as cm
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk

#From here, we are importing some essential backend tools from matplotlib
#namely the NavigationToolbar2GTK3 and the FigureCanvasGTK3Agg
from matplotlib.backends.backend_gtk3 import NavigationToolbar2GTK3 as NavigationToolbar
from matplotlib.backends.backend_gtk3agg import FigureCanvasGTK3Agg as FigureCanvas
from matplotlib.figure import Figure

#Some numpy functions to create the polar plot
from numpy import arange, pi, random, linspace

#Here we define our own class MatplotlibEmbed
#By simply instantiating this class through the __init__() function,
#A polar plot will be drawn by using Matplotlib, and embedded to GTK3+ window
class MatplotlibEmbed(Gtk.Window):

    #Instantiation
    def __init__(self):
        #Creating the Gtk Window
        Gtk.Window.__init__(self, title="Embedding Matplotlib")
        #Setting the size of the GTK window as 400,400
        self.set_default_size(400,400)

        #Readers should find it familiar, as we are creating a matplotlib figure here with a dpi(resolution) 100
        self.fig = Figure(figsize=(5,5), dpi=100)
        #The axes element, here we indicate we are creating 1x1 grid and putting the subplot in the only cell
        #Also we are creating a polar plot, therefore we set projection as 'polar
        self.ax = self.fig.add_subplot(111, projection='polar')

        #Here, we borrow one example shown in the matplotlib gtk3 cookbook
        #and show a beautiful bar plot on a circular coordinate system
        self.theta = linspace(0.0, 2 * pi, 30, endpoint=False)
        self.radii = 10 * random.rand(30)
        self.width = pi / 4 * random.rand(30)
        self.bars = self.ax.bar(self.theta, self.radii, width=self.width, bottom=0.0)

        #Here defines the color of the bar, as well as setting it to be transparent
        for r, bar in zip(self.radii, self.bars):
            bar.set_facecolor(cm.jet(r / 10.))
            bar.set_alpha(0.5)
        #Here we generate the figure
        self.ax.plot()

        #Here comes the magic, a Vbox is created
        #VBox is a containder subclassed from Gtk.Box, and it organizes its child widgets into a single column
        self.vbox = Gtk.VBox()
        #After creating the Vbox, we have to add it to the window object itself!
        self.add(self.vbox)

        #Creating Canvas which store the matplotlib figure
        self.canvas = FigureCanvas(self.fig)  # a Gtk.DrawingArea
        # Add canvas to vbox
        self.vbox.pack_start(self.canvas, True, True, 0)

        # Creating toolbar, which enables the save function!
        self.toolbar = NavigationToolbar(self.canvas, self)
        self.vbox.pack_start(self.toolbar, False, False, 0)

#The code here should be self-explanatory by now! Or refer to earlier examples for in-depth explanation
window = MatplotlibEmbed()
window.connect("delete-event", Gtk.main_quit)
window.show_all()
Gtk.main()

在这个例子中,我们创建了一个垂直框,并将画布(带有图表)和工具栏放入其中:

图 3

看起来很容易将 Matplotlib 图表直接整合到 GTK+3 中,不是吗?如果您有自己的图表想要将其插入 GTK+3 引擎中,只需扩展极坐标图表示例,然后您就可以使用此模板开始处理自己的图表了!

我们在这里额外做的一件事是创建了一个工具栏,并将其放置在图表的底部。请记住,我们在组织小部件时使用的是 VBox?这里的 V 代表垂直,即从上到下组织数据。因此,将工具栏放置在画布之后时,我们有这样的顺序。工具栏是一个优雅地修改和保存图表的好地方。

因此,让我们尝试几个例子,看看如何通过结合 GTK+3 和 Matplotlib 创建一些交互式图表。一个非常重要的概念是通过画布与 Matplotlib 建立事件连接;这可以通过调用mpl_connect()函数来实现。

Matplotlib Cookbook在线上可以找到许多好的例子。

让我们走过一个提供交互式放大功能的例子。这里是代码输出的预览:

图 4

窗口包括两个子图;左侧的图表是大图,而右侧的图表是放大版本。在左侧选择放大的区域由灰色框指定,灰色框可以随鼠标点击移动。这听起来可能有些复杂,但只需几行代码就可以轻松实现。我们建议读者首先阅读以下包含DrawPoints类的代码,并尝试从window = Gtk.Window()开始追溯逻辑。

下面是代码的详细解释:

#Same old, importing Gtk module, we are also importing some other stuff this time
#such as numpy and the backends of matplotlib
import gi, numpy as np, matplotlib.cm as cm
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk

#From here, we are importing some essential backend tools from matplotlib
#namely the NavigationToolbar2GTK3 and the FigureCanvasGTK3Agg
from numpy import random
from matplotlib.backends.backend_gtk3 import NavigationToolbar2GTK3 as NavigationToolbar
from matplotlib.backends.backend_gtk3agg import FigureCanvasGTK3Agg as FigureCanvas
from matplotlib.figure import Figure
from matplotlib.patches import Rectangle

#Here we created a class named DrawPoints
class DrawPoints:

    #Upon initiation, we create 4 randomized numpy array, those are for the coordinates, colors and size of dots
    #on the scatter plot. After that we create a figure object, put in two subplots and create a canvas to store
    #the figure.
    def __init__(self):
        #Namely we are creating 20 dots, therefore n = 20
        self.n = 20
        #X and Y coordinates
        self.xrand = random.rand(1,self.n)*10
        self.yrand = random.rand(1,self.n)*10
        #Sizes
        self.randsize = random.rand(1,self.n)*200
        #Colors
        self.randcolor = random.rand(self.n,3)

        #Here creates the figure, with a size 10x10 and resolution of 80dpi
        self.fig = Figure(figsize=(10,10), dpi=80)
        #Stating that we are creating two plots side by side and adding 
        #self.ax as the first plot by add_subplot(121)
        self.ax = self.fig.add_subplot(121)
        #Adding the second subplot by stating add_subplot(122)
        self.axzoom = self.fig.add_subplot(122)
        #Create a canvas to store the figure object
        self.canvas = FigureCanvas(self.fig)

    #Here draw the scatterplot on the left
    def draw(self):
        #Here is the key - cla(), when we invoke the draw() function, we have to clear the
        #figure and redraw it again
        self.ax.cla()
        #Setting the elements of the left subplot, in this case - grid
        self.ax.grid(True)
        #Set the maximum value of X and Y-axis in the left subplot
        self.ax.set_xlim(0,10)
        self.ax.set_ylim(0,10)
        #Draw the scatter plot with the randomized numpy array that we created earlier in __init__(self)
        self.ax.scatter(self.xrand, self.yrand, marker='o', s=self.randsize, c=self.randcolor, alpha=0.5)

    #This zoom function is invoked by updatezoom() function outside of the class Drawpoints
    #This function is responsible for things:
    #1\. Update X and Y coordinates based on the click
    #2\. invoke the draw() function to redraw the plot on the left, this is essential to update the position
    # of the grey rectangle 
    #3\. invoke the following drawzoom() function, which will "Zoom-in" the designated area by the grey rectangle
    # and will redraw the subplot on the right based on the updated X & Y coordinates
    #4\. draw a transparent grey rectangle based on the mouse click on the left subplot
    #5\. Update the canvas
    def zoom(self, x, y):
        #Here updates the X & Y coordinates
        self.x = x
        self.y = y
        #invoke the draw() function to update the subplot on the left
        self.draw()
        #invoke the drawzoom() function to update the subplot on the right
        self.drawzoom()
        #Draw the transparent grey rectangle at the subplot on the left
        self.ax.add_patch(Rectangle((x - 1, y - 1), 2, 2, facecolor="grey", alpha=0.2))
        #Update the canvas
        self.fig.canvas.draw()

    #This drawzoom function is being called in the zoom function
    #The idea is that, when the user picked a region (rectangle) to zoom, we need to redraw the zoomed panel,
    #which is the subplot on the right
    def drawzoom(self):
        #Again, we use the cla() function to clear the figure, and getting ready for a redraw!
        self.axzoom.cla()
        #Setting the grid
        self.axzoom.grid(True)
        #Do not be confused! Remember that we invoke this function from zoom, therefore self.x and self.y
        #are already updated in that function. In here, we are simply changing the X & Y-axis minimum and 
        #maximum value, and redraw the graph without changing any element!
        self.axzoom.set_xlim(self.x-1, self.x+1)
        self.axzoom.set_ylim(self.y-1, self.y+1)
        #By changing the X & Y-axis minimum and maximum value, the dots that are out of range will automatically
        #disappear!
        self.axzoom.scatter(self.xrand, self.yrand, marker='o', s=self.randsize*5, c=self.randcolor, alpha=0.5)

def updatecursorposition(event):
    '''When cursor inside plot, get position and print to statusbar'''
    if event.inaxes:
        x = event.xdata
        y = event.ydata
        statbar.push(1, ("Coordinates:" + " x= " + str(round(x,3)) + "  y= " + str(round(y,3))))

def updatezoom(event):
    '''When mouse is right-clicked on the canvas get the coordiantes and send them to points.zoom'''
    if event.button!=1: return
    if (event.xdata is None): return
    x,y = event.xdata, event.ydata
    points.zoom(x,y)

#Readers should be familiar with this now, here is the standard opening of the Gtk.Window()
window = Gtk.Window()
window.connect("delete-event", Gtk.main_quit)
window.set_default_size(800, 500)
window.set_title('Interactive zoom')

#Creating a vertical box, will have the canvas, toolbar and statbar being packed into it from top to bottom
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
#Adding the vertical box to the window
window.add(box)

#Instantiate the object points from the Class DrawPoints()
#Remember that at this point, __init__() of DrawPoints() are invoked upon construction!
points = DrawPoints()
#Invoke the draw() function in the object points
points.draw()

#Packing the canvas now to the vertical box
box.pack_start(points.canvas, True, True, 0)

#Creating and packing the toolbar to the vertical box
toolbar = NavigationToolbar(points.canvas, window)
box.pack_start(toolbar, False, True, 0)

#Creating and packing the statbar to the vertical box
statbar = Gtk.Statusbar()
box.pack_start(statbar, False, True, 0)

#Here is the magic that makes it happens, we are using mpl_connect to link the event and the canvas!
#'motion_notify_event' is responsible for the mouse motion sensing and position updating
points.fig.canvas.mpl_connect('motion_notify_event', updatecursorposition)
#'button_press_event' is slightly misleading, in fact it is referring to the mouse button being pressed, 
#instead of a GTK+3 button being pressed in this case
points.fig.canvas.mpl_connect('button_press_event', updatezoom)

window.show_all()
Gtk.main()

正如您从前面的例子中看到的,事件处理和选取是使交互部分比我们想象中更容易的元素。因此,重要的是快速回顾一下FigureCanvasBase中可用的事件连接。

事件名称类和描述
button_press_event鼠标事件: 鼠标按下按钮
button_release_eventMouseEvent: 鼠标按钮被释放
scroll_eventMouseEvent: 鼠标滚轮被滚动
motion_notify_eventMouseEvent: 鼠标移动
draw_eventDrawEvent: 画布绘制
key_press_eventKeyEvent: 键被按下
key_release_eventKeyEvent: 键被释放
pick_eventPickEvent: 画布中的一个对象被选中
resize_eventResizeEvent: 图形画布被调整大小
figure_enter_eventLocationEvent: 鼠标进入一个新图形
figure_leave_eventLocationEvent: 鼠标离开一个图形
axes_enter_eventLocationEvent: 鼠标进入一个新轴
axes_leave_eventLocationEvent: 鼠标离开一个轴

安装 Glade

安装 Glade 非常简单;你可以从其网页上获取源文件,或者直接使用 Git 获取最新版本的源代码。通过 Git 获取 Glade 的命令如下:

git clone git://git.gnome.org/glade

使用 Glade 设计 GUI

使用 Glade 设计 GUI 非常简单。只需启动 Glade 程序,你将看到这个界面(从 macOS 上显示,或者如果使用其他操作系统,则会看到类似的界面):

图 5

现在让我们来看看 Glade 界面。我们将主要使用四个按钮:顶级窗口容器控制显示。前面的截图显示,GtkWindow 列在了 顶级窗口 中,它作为构建的基本单元。点击 GtkWindow,看看会发生什么:

图 6

现在一个 GtkWindow 正在构建,但里面没有任何内容。让我们将这个 GtkWindow 的大小设置为:400x400。可以通过在右侧面板的下方设置默认宽度和高度为 400 来实现。右侧面板当前展示的是该 GtkWindow 的常规属性。

还记得我们在之前的示例中使用了很多垂直框吗?现在让我们在 GtkWindow 中添加一个垂直框!可以通过点击容器并选择 GtkBox 来实现,正如下图所示:

图 7

选择 GtkBox 后,点击中间面板中的 GtkWindow,GtkBox 将作为 GtkWindow 的子模块或子窗口创建。可以通过检查左侧面板来确认这一点,正如下图所示:

图 8

GtkBox 位于 GtkWindow 下方,并且在左侧面板中有缩进。由于我们选择了垂直框,所以在常规设置中,方向垂直。你还可以指定 GtkBox 中包含的间距和项数。现在让我们在顶部垂直框中添加一个菜单栏。可以参考图 9中的操作方法。在容器中,选择 GtkMenubar 并点击顶部垂直框。它将添加一个菜单栏,其中包含以下选项:文件、编辑、视图和帮助。

图 9

如同大家可以想象的,我们可以轻松地使用 Glade 设计我们喜欢的 GUI。我们可以导入一个具有自定义大小的标签,如图 10所示。还有许多其他选项,我们可以选择来自定义我们的 GUI。

图 10

通过 Glade 设计最有效的 GUI 超出了本书的范围,因此我们不会进一步探讨 Glade 中的高级选项。

然而,我们想扩展我们之前处理的一个示例,并展示将基于 Glade 的 GUI 融入到我们的工作流程中只需要几行代码。

首先,我们将使用基于类的极坐标图示例。首先,我们通过 Glade 设计最基本的 GtkWindow,大小为 400x400(就这样!),并将其保存为文件。

该文件非常简单且易于理解:

<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.1 -->
<interface>
  <requires lib="gtk+" version="3.22"/>
  <object class="GtkWindow" id="window1">
    <property name="can_focus">False</property>
    <property name="default_width">400</property>
    <property name="default_height">400</property>
    <signal name="destroy" handler="on_window1_destroy" swapped="no"/>
    <child>
      <object class="GtkScrolledWindow" id="scrolledwindow1">
        <property name="visible">True</property>
        <property name="can_focus">True</property>
        <property name="shadow_type">in</property>
        <child>
          <placeholder/>
        </child>
      </object>
    </child>
  </object>
</interface>

读者可能理解我们只是创建了一个大小为 400x400 的 GtkWindow,并且添加了一个作为 GtkScrolledWindow 的子元素。这可以在 Glade 中通过几次点击完成。

现在我们要做的是使用 Gtk.Builder() 来读取 Glade 文件;一切都会自动构建。实际上,这为我们节省了定义垂直框架所有元素的工作!

#Same old, importing Gtk module, we are also importing some other stuff this time
#such as numpy and the backends of matplotlib
import gi, numpy as np, matplotlib.cm as cm
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk

from matplotlib.figure import Figure
from numpy import arange, pi, random, linspace
import matplotlib.cm as cm
#Possibly this rendering backend is broken currently
from matplotlib.backends.backend_gtk3agg import FigureCanvasGTK3Agg as FigureCanvas

#New class, here is to invoke Gtk.main_quit() when the window is being destroyed
#Necessary to quit the Gtk.main()
class Signals:
    def on_window1_destroy(self, widget):
        Gtk.main_quit()

class MatplotlibEmbed(Gtk.Window):

    #Instantiation, we just need the canvas to store the figure!
    def __init__(self):

        #Readers should find it familiar, as we are creating a matplotlib figure here with a dpi(resolution) 100
        self.fig = Figure(figsize=(5,5), dpi=100)
        #The axes element, here we indicate we are creating 1x1 grid and putting the subplot in the only cell
        #Also we are creating a polar plot, therefore we set projection as 'polar
        self.ax = self.fig.add_subplot(111, projection='polar')

        #Here, we borrow one example shown in the matplotlib gtk3 cookbook
        #and show a beautiful bar plot on a circular coordinate system
        self.theta = linspace(0.0, 2 * pi, 30, endpoint=False)
        self.radii = 10 * random.rand(30)
        self.width = pi / 4 * random.rand(30)
        self.bars = self.ax.bar(self.theta, self.radii, width=self.width, bottom=0.0)

        #Here defines the color of the bar, as well as setting it to be transparent
        for r, bar in zip(self.radii, self.bars):
            bar.set_facecolor(cm.jet(r / 10.))
            bar.set_alpha(0.5)
        #Here we generate the figure
        self.ax.plot()

        #Creating Canvas which store the matplotlib figure
        self.canvas = FigureCanvas(self.fig)  # a Gtk.DrawingArea

#Here is the magic, we create a GTKBuilder that reads textual description of a user interface
#and instantiates the described objects
builder = Gtk.Builder()
#We ask the GTKBuilder to read the file and parse the information there
builder.add_objects_from_file('/Users/aldrinyim/Dropbox/Matplotlib for Developer/Jupyter notebook/ch05/window1_glade.glade', ('window1', '') )
#And we connect the terminating signals with Gtk.main_quit()
builder.connect_signals(Signals())

#We create the first object window1
window1 = builder.get_object('window1')
#We create the second object scrollwindow
scrolledwindow1 = builder.get_object('scrolledwindow1')

#Instantiate the object and start the drawing!
polar_drawing = MatplotlibEmbed()
#Add the canvas to the scrolledwindow1 object
scrolledwindow1.add(polar_drawing.canvas)

#Show all and keep the Gtk.main() active!
window1.show_all()
Gtk.main()

前面的代码演示了我们如何使用 Glade 快速生成一个框架并轻松执行它。

图 11

希望通过这个示例,读者能更好地理解 Glade 的强大功能,它使程序员能够通过可视化的方式来设计 GUI,而不是通过代码抽象化。这在 GUI 变得复杂时特别有用。

总结

在本章中,我们通过实例讲解了如何将 Matplotlib 图形嵌入到简单的 GTK+3 窗口中,添加 Matplotlib 导航工具栏,在交互式框架中绘制数据,以及使用 Glade 设计 GUI。我们保持了实例的简洁,以突出重点部分,但我们鼓励读者进一步探索更多可能性。GTK+3 不是唯一可以使用的 GUI 库,在接下来的章节中,我们将看到如何使用另外两个重要的库!

第六章:在 Qt 5 中嵌入 Matplotlib

有多种 GUI 库可供选择,其中一个广泛使用的库是 Qt。在本书中,我们将使用 Qt 5,这是该库的最新主要版本。除非明确提及,否则我们在本章节中提到的 Qt 都是指 Qt 5。

我们将遵循与 第五章 在 GTK+3 中嵌入 Matplotlib 类似的进度,展示类似的示例,但这次是用 Qt 编写的。

我们认为这种方法将使我们能够直接比较各个库,并且它的优点是不会留下 我如何使用库 X 编写某个东西? 这个问题没有答案。

在本章中,我们将学习如何:

  • 将 Matplotlib 图形嵌入到 Qt 小部件中

  • 将图形和导航工具栏嵌入到 Qt 小部件中

  • 使用事件实时更新 Matplotlib 图形

  • 使用 QT Designer 绘制 GUI,然后在简单的 Python 应用程序中与 Matplotlib 一起使用

我们将从对该库的介绍开始。

Qt 5 和 PyQt 5 的简要介绍

Qt 是一个跨平台的应用程序开发框架,广泛用于图形程序(GUI)以及非图形工具。

Qt 由 Trolltech(现为诺基亚所有)开发,可能最著名的是作为 K 桌面环境 (KDE) 的基础,KDE 是 Linux 的桌面环境。

Qt 工具包是一个类集合,旨在简化程序的创建。Qt 不仅仅是一个 GUI 工具包,它还包括用于网络套接字、线程、Unicode、正则表达式、SQL 数据库、SVG、OpenGL 和 XML 的抽象组件。它还具有一个完全功能的 Web 浏览器、帮助系统、多媒体框架以及丰富的 GUI 小部件集合。

Qt 可在多个平台上使用,尤其是 Unix/Linux、Windows、macOS X,以及一些嵌入式设备。由于它使用平台的原生 API 来渲染 Qt 控件,因此使用 Qt 开发的应用程序具有适合运行环境的外观和感觉(而不会看起来像是外来物)。

尽管 Qt 是用 C++ 编写的,但通过可用于 Ruby、Java、Perl 以及通过 PyQt 也支持 Python,Qt 也可以在多个其他编程语言中使用。

PyQt 5 可用于 Python 2.x 和 3.x,但在本书中,我们将在所有代码中一致使用 Python 3。PyQt 5 包含超过 620 个类和 6,000 个函数和方法。在我们进行一些示例之前,了解 Qt 4/PyQt 4 和 Qt 5/PyQt 5 之间的区别是很重要的。

Qt 4 和 PyQt 4 之间的区别

PyQt 是 Qt 框架的全面 Python 绑定集。然而,PyQt 5 与 PyQt 4 不兼容。值得注意的是,PyQt 5 不支持 Qt v5.0 中标记为弃用或过时的任何 Qt API。尽管如此,可能会偶尔包含一些这些 API。如果包含了它们,它们被视为错误,并在发现时被删除。

如果你熟悉 Qt 4 或者已经读过本书的第一版,需要注意的是,信号与槽的机制在 PyQt 5 中已不再被支持。因此,以下内容在 PyQt 5 中未实现:

  • QtScript

  • QObject.connect()

  • QObject.emit()

  • SIGNAL()

  • SLOT()

此外,disconnect()也做了修改,调用时不再需要参数,且会断开所有与QObject实例的连接。

然而,已经引入了新模块,如下所示:

  • QtBluetooth

  • QtPositioning

  • Enginio

让我们从一个非常简单的例子开始——调用一个窗口。同样,为了获得最佳性能,请复制代码,将其粘贴到文件中,并在终端中运行脚本。我们的代码仅优化用于在终端中运行:

#sys.argv is essential for the instantiation of QApplication!
import sys
#Here we import the PyQt 5 Widgets
from PyQt5.QtWidgets import QApplication, QWidget

#Creating a QApplication object
app = QApplication(sys.argv)
#QWidget is the base class of all user interface objects in PyQt5
w = QWidget()
#Setting the width and height of the window
w.resize(250, 150)
#Move the widget to a position on the screen at x=500, y=500 coordinates
w.move(500, 500)
#Setting the title of the window
w.setWindowTitle('Simple')
#Display the window
w.show()

#app.exec_() is the mainloop of the application
#the sys.exit() is a method to ensure a real exit upon receiving the signal of exit from the app
sys.exit(app.exec_())

语法与你在第五章中看到的将 Matplotlib 嵌入 GTK+3非常相似。一旦你对某个特定的 GUI 库(例如 GTK+3)掌握得比较好,就可以很容易地适应新的 GUI 库。代码与 GTK+3 非常相似,逻辑也跟着走。QApplication管理 GUI 应用程序的控制流和主要设置。它是主事件循环执行、处理和分发的地方。它还负责应用程序的初始化和最终化,并处理大多数系统级和应用级的设置。由于QApplication处理整个初始化阶段,因此必须在创建与 UI 相关的任何其他对象之前创建它。

qApp.exec_()命令进入 Qt 主事件循环。一旦调用了exit()quit(),它就会返回相关的返回码。在主循环开始之前,屏幕上不会显示任何内容。调用此函数是必要的,因为主循环处理来自应用程序小部件和窗口系统的所有事件和信号;本质上,在调用之前无法进行任何用户交互。

读者可能会疑惑,为什么exec_();中有一个下划线。原因很简单:exec()是 Python 中的保留字,因此在exec()的 Qt 方法中加了下划线。将其包装在sys.exit()内,可以让 Python 脚本以相同的返回码退出,告知环境应用程序的结束状态(无论是成功还是失败)。

对于经验更丰富的读者,你会发现前面的代码中有些不寻常的地方。当我们实例化QApplication类时,需要将sys.argv(在此情况下是一个空列表)传递给QApplication的构造函数。至少当我第一次使用 PyQt 时,这让我感到意外,但这是必须的,因为实例化会调用 C++类QApplication的构造函数,并且它使用sys.argv来初始化 Qt 应用程序。在QApplication实例化时解析sys.argv是 Qt 中的一种约定,需要特别注意。另外,每个 PyQt 5 应用程序必须创建一个应用程序对象。

再次尝试以面向对象编程(OOP)风格编写另一个示例:

#Described in earlier examples
import sys
from PyQt5.QtWidgets import QWidget, QPushButton, QHBoxLayout, QVBoxLayout, QApplication

#Here we create a class with the "base" class from QWidget
#We are inheriting the functions of the QWidget from this case
class Qtwindowexample(QWidget):

    #Constructor, will be executed upon instantiation of the object
    def __init__(self):
        #Upon self instantiation, we are calling constructor of the QWidget 
        #to set up the bases of the QWidget's object 
        QWidget.__init__(self)

        #Resizing, moving and setting the window
        self.resize(250, 150)
        self.move(300, 300)
        self.setWindowTitle('2 Click buttons!')

        #Here we create the first button - print1button
        #When clicked, it will invoke the printOnce function, and print "Hello world" in the terminal
        self.print1button = QPushButton('Print once!', self)
        self.print1button.clicked.connect(self.printOnce)

        #Here we create the second button - print5button
        #When clicked, it will invoke the printFive function, and print "**Hello world" 5 times in the terminal
        self.print5button = QPushButton('Print five times!', self)
        self.print5button.clicked.connect(self.printFive)

        #Something very familiar!
        #It is the vertical box in Qt5
        self.vbox=QVBoxLayout()

        #Simply add the two buttons to the vertical box 
        self.vbox.addWidget(self.print1button)
        self.vbox.addWidget(self.print5button)

        #Here put the vertical box into the window
        self.setLayout(self.vbox)
        #And now we are all set, show the window!
        self.show()

    #Function that will print Hello world once when invoked
    def printOnce(self):
        print("Hello World!")

    #Function that will print **Hello world five times when invoked
    def printFive(self):
        for i in range(0,5):
            print("**Hello World!")

#Creating the app object, essential for all Qt usage
app = QApplication(sys.argv)
#Create Qtwindowexample(), construct the window and show!
ex = Qtwindowexample()
#app.exec_() is the mainloop of the application
#the sys.exit() is a method to ensure a real exit upon receiving the signal of exit from the app
sys.exit(app.exec_())

上述代码创建了两个按钮,每个按钮都会调用一个独立的函数——在终端中打印一次 Hello world 或打印五次 Hello World。读者应该能轻松理解代码中的事件处理系统。

这是输出结果:

这是来自第五章的另一个两个按钮示例,将 Matplotlib 嵌入 GTK+3,这个示例的目的是展示在 PyQt 5 中的信号处理方法,并与 GTK+3 进行对比。读者应该会发现它非常相似,因为我们故意将其写得更接近 GTK+3 示例。

让我们尝试将 Matplotlib 图形嵌入 Qt 窗口。请注意,与上一章的示例不同,这个图形将每秒刷新一次!因此,我们也在这里使用了 QtCore.QTimer() 函数,并将 update_figure() 函数作为事件-动作对进行调用:

#Importing essential libraries
import sys, os, random, matplotlib, matplotlib.cm as cm
from numpy import arange, sin, pi, random, linspace
#Python Qt5 bindings for GUI objects
from PyQt5 import QtCore, QtWidgets
# import the Qt5Agg FigureCanvas object, that binds Figure to
# Qt5Agg backend.
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure

#The class DynamicCanvas contains all the functions required to draw and update the figure
#It contains a canvas that updates itself every second with newly randomized vecotrs
class DynamicCanvas(FigureCanvas):

    #Invoke upon instantiation, here are the arguments parsing along
    def __init__(self, parent=None, width=5, height=4, dpi=100):
        #Creating a figure with the requested width, height and dpi
        fig = Figure(figsize=(width,height), dpi=dpi)

        #The axes element, here we indicate we are creating 1x1 grid and putting the subplot in the only cell
        #Also we are creating a polar plot, therefore we set projection as 'polar
        self.axes = fig.add_subplot(111, projection='polar')
        #Here we invoke the function "compute_initial_figure" to create the first figure
        self.compute_initial_figure()

        #Creating a FigureCanvas object and putting the figure into it
        FigureCanvas.__init__(self, fig)
        #Setting this figurecanvas parent as None
        self.setParent(parent)

        #Here we are using the Qtimer function
        #As you can imagine, it functions as a timer and will emit a signal every N milliseconds
        #N is defined by the function QTimer.start(N), in this case - 1000 milliseconds = 1 second
        #For every second, this function will emit a signal and invoke the update_figure() function defined below
        timer = QtCore.QTimer(self)
        timer.timeout.connect(self.update_figure)
        timer.start(1000)

    #For drawing the first figure
    def compute_initial_figure(self):
        #Here, we borrow one example shown in the matplotlib gtk3 cookbook
        #and show a beautiful bar plot on a circular coordinate system
        self.theta = linspace(0.0, 2 * pi, 30, endpoint=False)
        self.radii = 10 * random.rand(30)
        self.plot_width = pi / 4 * random.rand(30)
        self.bars = self.axes.bar(self.theta, self.radii, width=self.plot_width, bottom=0.0)

        #Here defines the color of the bar, as well as setting it to be transparent
        for r, bar in zip(self.radii, self.bars):
            bar.set_facecolor(cm.jet(r / 10.))
            bar.set_alpha(0.5)
        #Here we generate the figure
        self.axes.plot()

    #This function will be invoke every second by the timeout signal from QTimer
    def update_figure(self):
        #Clear figure and get ready for the new plot
        self.axes.cla()

        #Identical to the code above
        self.theta = linspace(0.0, 2 * pi, 30, endpoint=False)
        self.radii = 10 * random.rand(30)
        self.plot_width = pi / 4 * random.rand(30)
        self.bars = self.axes.bar(self.theta, self.radii, width=self.plot_width, bottom=0.0)

        #Here defines the color of the bar, as well as setting it to be transparent
        for r, bar in zip(self.radii, self.bars):
            bar.set_facecolor(cm.jet(r / 10.))
            bar.set_alpha(0.5)

        #Here we generate the figure
        self.axes.plot()
        self.draw()

#This class will serve as our main application Window
#QMainWindow class provides a framework for us to put window and canvas
class ApplicationWindow(QtWidgets.QMainWindow):

    #Instantiation, initializing and setting up the framework for the canvas
    def __init__(self):
        #Initializing of Qt MainWindow widget
        QtWidgets.QMainWindow.__init__(self)
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
        #Instantiating QWidgets object
        self.main_widget = QtWidgets.QWidget(self)

        #Creating a vertical box!
        vbox = QtWidgets.QVBoxLayout(self.main_widget)
        #Creating the dynamic canvas and this canvas will update itself!
        dc = DynamicCanvas(self.main_widget, width=5, height=4, dpi=100)
        #adding canvas to the vertical box
        vbox.addWidget(dc)

        #This is not necessary, but it is a good practice to setFocus on your main widget
        self.main_widget.setFocus()
        #This line indicates that main_widget is the main part of the application
        self.setCentralWidget(self.main_widget)

#Creating the GUI application
qApp = QtWidgets.QApplication(sys.argv)
#Instantiating the ApplicationWindow widget
aw = ApplicationWindow()
#Set the title
aw.setWindowTitle("Dynamic Qt5 visualization")
#Show the widget
aw.show()
#Start the Qt main loop , and sys.exit() ensure clean exit when closing the window
sys.exit(qApp.exec_())

同样,本示例中的图形会通过 QTimer 随机化数据,并每秒更新一次,具体如下:

引入 QT Creator / QT Designer

上面四个图形是 PyQt 5 窗口的截图,它会每秒刷新一次。

对于简单的示例,直接在 Python 代码中设计 GUI 已经足够,但对于更复杂的应用程序,这种解决方案无法扩展。

有一些工具可以帮助你为 Qt 设计 GUI,其中一个最常用的工具是 QT Designer。在本书的第一版中,本部分讲述的是如何使用 QT Designer 制作 GUI。自从 QT4 后期开发以来,QT Designer 已经与 QT Creator 合并。在接下来的示例中,我们将学习如何在 QT Creator 中打开隐藏的 QT Designer 并创建一个 UI 文件。

类似于 Glade,我们可以通过屏幕上的表单和拖放界面设计应用程序的用户界面。然后,我们可以将小部件与后端代码连接,在那里我们开发应用程序的逻辑。

首先,让我们展示如何在 QT Creator 中打开 QT Designer。当你打开 QT Creator 时,界面如下所示:

难点在于:不要通过点击 Creator 中的“新建文件”或“新建项目”按钮来创建项目。相反,选择“新建项目”:

在文件和类中选择 Qt,并在中间面板中选择 Qt Designer Form:

有一系列模板选择,如 Widget 或 Main Window。在我们的例子中,我们选择 Main Window,并简单地按照其余步骤进行操作:

最终,我们将进入 QT Designer 界面。你在这里做的所有工作将被保存到你指定的文件夹中,作为 UI 文件:

在使用 QT Creator / QT Designer 制作的 GUI 中嵌入 Matplotlib。

为了快速演示如何使用 QT Creator 在 Qt 5 中嵌入 Matplotlib 图形,我们将使用前面的例子,并将其与 QT Creator 生成的脚本结合起来。

首先,在右下角面板调整 MainWindow 的 Geometry;将宽度和高度改为 300x300:

然后,从左侧面板的 Container 中拖动一个 Widget 到中间的 MainWindow 中。调整大小,直到它恰好适合 MainWindow:

基本设计就是这样!现在将其保存为 UI 文件。当你查看 UI 文件时,它应该显示如下内容:

<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>MainWindow</class>
 <widget class="QMainWindow" name="MainWindow">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>300</width>
    <height>300</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>MainWindow</string>
  </property>
  <widget class="QWidget" name="centralwidget">
   <widget class="QWidget" name="widget" native="true">
    <property name="geometry">
     <rect>
      <x>20</x>
      <y>10</y>
      <width>261</width>
      <height>221</height>
     </rect>
    </property>
   </widget>
  </widget>
  <widget class="QMenuBar" name="menubar">
   <property name="geometry">
    <rect>
     <x>0</x>
     <y>0</y>
     <width>300</width>
     <height>22</height>
    </rect>
   </property>
  </widget>
  <widget class="QStatusBar" name="statusbar"/>
 </widget>
 <resources/>
 <connections/>
</ui>

这个文件是 XML 格式的,我们需要将它转换为 Python 文件。可以简单地通过使用以下命令来完成:

pyuic5 mainwindow.ui > mainwindow.py

现在我们将得到一个像这样的 Python 文件:

from PyQt5 import QtCore, QtGui, QtWidgets

class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        MainWindow.setObjectName("MainWindow")
        MainWindow.resize(300, 300)
        self.centralwidget = QtWidgets.QWidget(MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        self.widget = QtWidgets.QWidget(self.centralwidget)
        self.widget.setGeometry(QtCore.QRect(20, 10, 261, 221))
        self.widget.setObjectName("widget")
        MainWindow.setCentralWidget(self.centralwidget)
        self.menubar = QtWidgets.QMenuBar(MainWindow)
        self.menubar.setGeometry(QtCore.QRect(0, 0, 300, 22))
        self.menubar.setObjectName("menubar")
        MainWindow.setMenuBar(self.menubar)
        self.statusbar = QtWidgets.QStatusBar(MainWindow)
        self.statusbar.setObjectName("statusbar")
        MainWindow.setStatusBar(self.statusbar)

        self.retranslateUi(MainWindow)
        QtCore.QMetaObject.connectSlotsByName(MainWindow)

    def retranslateUi(self, MainWindow):
        _translate = QtCore.QCoreApplication.translate
        MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))

请注意,这只是 GUI 的框架;我们仍然需要添加一些内容才能使其正常工作。

我们必须添加init()来初始化UiMainWindow,并将DynamicCanvasMainWindow中间的 widget 连接起来。具体如下:

#Replace object to QtWidgets.QMainWindow
class Ui_MainWindow(QtWidgets.QMainWindow):

    #***Instantiation!
    def __init__(self):
        # Initialize and display the user interface
        QtWidgets.QMainWindow.__init__(self)
        self.setupUi(self)

    def setupUi(self, MainWindow):
        MainWindow.setObjectName("MainWindow")
        MainWindow.resize(300, 300)
        self.centralwidget = QtWidgets.QWidget(MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        self.widget = QtWidgets.QWidget(self.centralwidget)
        self.widget.setGeometry(QtCore.QRect(20, 10, 261, 221))
        self.widget.setObjectName("widget")
        MainWindow.setCentralWidget(self.centralwidget)
        self.menubar = QtWidgets.QMenuBar(MainWindow)
        self.menubar.setGeometry(QtCore.QRect(0, 0, 300, 22))
        self.menubar.setObjectName("menubar")
        MainWindow.setMenuBar(self.menubar)
        self.statusbar = QtWidgets.QStatusBar(MainWindow)
        self.statusbar.setObjectName("statusbar")
        MainWindow.setStatusBar(self.statusbar)

        self.retranslateUi(MainWindow)
        QtCore.QMetaObject.connectSlotsByName(MainWindow)

        #***Putting DynamicCanvas into the widget, and show the window!
        dc = DynamicCanvas(self.widget, width=5, height=4, dpi=100)
        self.show()

    def retranslateUi(self, MainWindow):
        _translate = QtCore.QCoreApplication.translate
        MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))

我们在这里只添加了五行代码。我们可以简单地用这个替换ApplicationWindow类,最终的结果如下:

这是生成上述图形的完整代码:

#Importing essential libraries
import sys, os, random, matplotlib, matplotlib.cm as cm
from numpy import arange, sin, pi, random, linspace
#Python Qt5 bindings for GUI objects
from PyQt5 import QtCore, QtGui, QtWidgets
# import the Qt5Agg FigureCanvas object, that binds Figure to
# Qt5Agg backend.
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure

#The class DynamicCanvas contains all the functions required to draw and update the figure
#It contains a canvas that updates itself every second with newly randomized vecotrs
class DynamicCanvas(FigureCanvas):

    #Invoke upon instantiation, here are the arguments parsing along
    def __init__(self, parent=None, width=5, height=5, dpi=100):
        #Creating a figure with the requested width, height and dpi
        fig = Figure(figsize=(width,height), dpi=dpi)

        #The axes element, here we indicate we are creating 1x1 grid and putting the subplot in the only cell
        #Also we are creating a polar plot, therefore we set projection as 'polar
        self.axes = fig.add_subplot(111, projection='polar')
        #Here we invoke the function "compute_initial_figure" to create the first figure
        self.compute_initial_figure()

        #Creating a FigureCanvas object and putting the figure into it
        FigureCanvas.__init__(self, fig)
        #Setting this figurecanvas parent as None
        self.setParent(parent)

        #Here we are using the Qtimer function
        #As you can imagine, it functions as a timer and will emit a signal every N milliseconds
        #N is defined by the function QTimer.start(N), in this case - 1000 milliseconds = 1 second
        #For every second, this function will emit a signal and invoke the update_figure() function defined below
        timer = QtCore.QTimer(self)
        timer.timeout.connect(self.update_figure)
        timer.start(1000)

    #For drawing the first figure
    def compute_initial_figure(self):
        #Here, we borrow one example shown in the matplotlib gtk3 cookbook
        #and show a beautiful bar plot on a circular coordinate system
        self.theta = linspace(0.0, 2 * pi, 30, endpoint=False)
        self.radii = 10 * random.rand(30)
        self.plot_width = pi / 4 * random.rand(30)
        self.bars = self.axes.bar(self.theta, self.radii, width=self.plot_width, bottom=0.0)

        #Here defines the color of the bar, as well as setting it to be transparent
        for r, bar in zip(self.radii, self.bars):
            bar.set_facecolor(cm.jet(r / 10.))
            bar.set_alpha(0.5)
        #Here we generate the figure
        self.axes.plot()

    #This function will be invoke every second by the timeout signal from QTimer
    def update_figure(self):
        #Clear figure and get ready for the new plot
        self.axes.cla()

        #Identical to the code above
        self.theta = linspace(0.0, 2 * pi, 30, endpoint=False)
        self.radii = 10 * random.rand(30)
        self.plot_width = pi / 4 * random.rand(30)
        self.bars = self.axes.bar(self.theta, self.radii, width=self.plot_width, bottom=0.0)

        #Here defines the color of the bar, as well as setting it to be transparent
        for r, bar in zip(self.radii, self.bars):
            bar.set_facecolor(cm.jet(r / 10.))
            bar.set_alpha(0.5)

        #Here we generate the figure
        self.axes.plot()
        self.draw()

#Created by Qt Creator!
class Ui_MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        # Initialize and display the user interface
        QtWidgets.QMainWindow.__init__(self)
        self.setupUi(self)

    def setupUi(self, MainWindow):
        MainWindow.setObjectName("MainWindow")
        MainWindow.resize(550, 550)
        self.centralwidget = QtWidgets.QWidget(MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        self.widget = QtWidgets.QWidget(self.centralwidget)
        self.widget.setGeometry(QtCore.QRect(20, 10, 800, 800))
        self.widget.setObjectName("widget")
        MainWindow.setCentralWidget(self.centralwidget)
        self.menubar = QtWidgets.QMenuBar(MainWindow)
        self.menubar.setGeometry(QtCore.QRect(0, 0, 300, 22))
        self.menubar.setObjectName("menubar")
        MainWindow.setMenuBar(self.menubar)
        self.statusbar = QtWidgets.QStatusBar(MainWindow)
        self.statusbar.setObjectName("statusbar")
        MainWindow.setStatusBar(self.statusbar)

        self.retranslateUi(MainWindow)
        QtCore.QMetaObject.connectSlotsByName(MainWindow)

        dc = DynamicCanvas(self.widget, width=5, height=5, dpi=100)
        #self.centralwidget.setFocus()
        #self.setCentralWidget(self.centralwidget)
        self.show()

    def retranslateUi(self, MainWindow):
        _translate = QtCore.QCoreApplication.translate
        MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))

#Creating the GUI application
qApp = QtWidgets.QApplication(sys.argv)
#Instantiating the ApplicationWindow widget
aw = Ui_MainWindow()
#Start the Qt main loop , and sys.exit() ensure clean exit when closing the window
sys.exit(qApp.exec_())

总结

使用 QT Creator / QT Designer 进行 GUI 设计本身就有足够的内容可以写成一本书。因此,在本章中,我们旨在通过 PyQt 5 向你展示 GUI 设计的冰山一角。完成本章后,读者应能理解如何在 QWidget 中嵌入图形,使用布局管理器将图形放入 QWidget 中,创建计时器,响应事件并相应更新 Matplotlib 图形,以及使用 QT Designer 为 Matplotlib 嵌入绘制一个简单的 GUI。

我们现在准备学习另一个 GUI 库,wxWidgets。

第七章:使用 wxPython 将 Matplotlib 嵌入 wxWidgets

本章将解释如何在 wxWidgets 框架中使用 Matplotlib,特别是通过 wxPython 绑定。

本章内容如下:

  • wxWidgets 和 wxPython 的简要介绍

  • 嵌入 Matplotlib 到 wxWidgets 的一个简单示例

  • 将前一个示例扩展,包含 Matplotlib 导航工具栏

  • 如何使用 wxWidgets 框架实时更新 Matplotlib 图表

  • 如何使用 wxGlade 设计 GUI 并将 Matplotlib 图形嵌入其中

让我们从 wxWidgets 和 wxPython 的特点概述开始。

wxWidgets 和 wxPython 的简要介绍

wxWidgets 最重要的特性之一是跨平台的可移植性;它目前支持 Windows、macOS X、Linux(支持 X11、Motif 和 GTK+ 库)、OS/2 和多个其他操作系统与平台(包括正在开发中的嵌入式版本)。

wxWidgets 最好描述为一种本地模式工具包,因为它在各个平台之间提供了一个薄的 API 抽象层,并且在后台使用平台本地的控件,而不是模拟它们。使用本地控件使得 wxWidgets 应用程序具有自然且熟悉的外观和感觉。另一方面,引入额外的层次可能会导致轻微的性能损失,尽管在我们常开发的应用程序中,这种损失不太容易察觉。

wxWidgets 并不仅限于 GUI 开发。它不仅仅是一个图形工具包,还提供了一整套额外的功能,如数据库库、进程间通信层、网络功能等。虽然它是用 C++ 编写的,但有许多绑定可供多种常用编程语言使用。其中包括由 wxPython 提供的 Python 绑定。

wxPython(可在 www.wxpython.org/ 获取)是一个 Python 扩展模块,提供了来自 wxWidgets 库的 Python 语言绑定。这个扩展模块允许 Python 程序员创建 wxWidgets 类的实例,并调用这些类的方法。

现在是引入 wxPython 的好时机,因为 wxPython 4 在一年前发布了。到目前为止(2018 年 4 月),wxPython 的最新版本是 4.0.1,并且它与 Python 2 和 Python 3 都兼容。

从 2010 年开始,凤凰计划是清理 wxPython 实现并使其兼容 Python 3 的努力。正如大家所想,wxPython 完全重写,重点放在了性能、可维护性和可扩展性上。

让我们走一遍使用 wxPython 的最基本示例!

#Here imports the wxPython library
import wx
#Every wxPython app is an instance of wx.App
app = wx.App(False)
#Here we create a wx.Frame() and specifying it as a top-level window
#by stating "None" as a parent object
frame = wx.Frame(None, wx.ID_ANY, "Hello World")
#Show the frame!
frame.Show(True)
#Start the applciation's MainLoop, and ready for events handling
app.MainLoop()

在前面的示例基础上,有一件非常重要的事情是初学者需要注意的。

wx.Framewx.Window()是非常不同的。wx.Window是 wxWidgets 中所有视觉元素的基类,如按钮和菜单;在 wxWidgets 中,我们通常将程序窗口称为wx.Frame

构造wx.Frame的语法为wx.Frame(Parent, ID, Title)。当将Parent指定为None时,如刚才所示,我们实际上是在说这个框架是一个顶级window

wxWidgets 中还有一个ID 系统。各种控件和 wxWidgets 的其他部分都需要一个 ID。有时,ID 由用户提供;另外,ID 也有预定义的值。然而,在大多数情况下(如前面的示例),ID 的值并不重要,我们可以使用wx.ID_ANY作为对象的 ID,告诉 wxWidgets 自动分配 ID。请记住,所有自动分配的 ID 都是负数,而用户定义的 ID 应始终为正数,以避免与自动分配的 ID 冲突。

现在,让我们来看一个需要事件处理的面向对象风格的示例——Hello world按钮示例:

#Here imports the wxPython library
import wx

#Here is the class for the Frame inheriting from wx.Frame
class MyFrame(wx.Frame):

    #Instantiation based on the constructor defined below
    def __init__(self, parent):
        #creating the frame object and assigning it to self
        wx.Frame.__init__(self, parent, wx.ID_ANY)
        #Create panel
        self.panel = wx.Panel(self)
        #wx.BoxSizer is essentially the vertical box,
        #and we will add the buttons to the BoxSizer
        self.sizer = wx.BoxSizer(wx.VERTICAL)

        #Creating button 1 that will print Hello World once
        self.button1 = wx.Button(self.panel,label="Hello World!")
        #Create button 2 that will print Hello World twice
        self.button2 = wx.Button(self.panel,label="Hello World 5 times!")

        #There are two ways to bind the button with the event, here is method 1:
        self.button1.Bind(wx.EVT_BUTTON, self.OnButton1)
        self.button2.Bind(wx.EVT_BUTTON, self.OnButton2)

        #Here is method 2: 
        #self.Bind(wx.EVT_BUTTON, self.OnButton1, self.button1)
        #self.Bind(wx.EVT_BUTTON, self.OnButton2, self.button2)

        #Here we add the button to the BoxSizer
        self.sizer.Add(self.button1,0,0,0)
        self.sizer.Add(self.button2,0,0,0)

        #Put sizer into panel
        self.panel.SetSizer(self.sizer)

    #function that will be invoked upon pressing button 1
    def OnButton1(self,event):
        print("Hello world!")

    #function that will be invoked upon pressing button 2
    def OnButton2(self,event):
        for i in range(0,5):
            print("Hello world!")

#Every wxPython app is an instance of wx.App
app = wx.App()
#Here we create a wx.Frame() and specifying it as a top-level window
#by stating "None" as a parent object
frame = MyFrame(None)
#Show the frame!
frame.Show()
#Start the applciation's MainLoop, and ready for events handling
app.MainLoop()

输出结果如下:

正如读者可能注意到的,我们讨论过的所有三个 GUI 库的语法都很相似。因此,熟悉其中一个库后,你可以轻松地在它们之间切换。

wxWidgets 的布局管理器是sizer小部件:它们是小部件(包括其他 sizer)的容器,根据我们的配置处理小部件尺寸的视觉排列。BoxSizer接受一个参数,表示其方向。在这种情况下,我们传递常量wx.VERTICAL来将小部件按列排列;如果需要一排小部件,也可以使用常量wx.HORIZONTAL

self.sizer.Add(self.button1, 1, wx.LEFT | wx.TOP | wx.EXPAND)

我们现在能够将FigureCanvas对象添加到sizer中了。Add()函数的参数非常重要:

  • 第一个参数是要添加的对象的引用。

  • 然后,我们有第二个参数——比例。这个值用于表示应将多少额外的空闲空间分配给此小部件。通常,GUI 中的小部件不会占据所有空间,因此会有一些额外的空闲空间可用。这些空间会根据每个小部件与 GUI 中所有小部件的比例值进行重新分配。举个例子:如果我们有三个小部件,它们的比例分别为012,那么第一个(比例为0)将完全不变化。第三个(比例为2)将比第二个(比例为1)变化两倍。在书中的示例中,我们将比例设置为1,因此我们声明该小部件在调整大小时应占用一份空闲空间。

  • 第三个参数是一个标志组合,用于进一步配置在sizer中小部件的行为。它控制边框、对齐、各小部件之间的间隔以及扩展。在这里,我们声明FigureCanvas应在窗口调整大小时进行扩展。

让我们尝试一个示例,将 Matplotlib 图形(极坐标图)嵌入到由 wxWidgets 支持的 GUI 中:

#Specifying that we are using WXAgg in matplotlib
import matplotlib
matplotlib.use('WXAgg')
#Here imports the wxPython and other supporting libraries
import wx, sys, os, random, matplotlib, matplotlib.cm as cm, matplotlib.pyplot as plt
from numpy import arange, sin, pi, random, linspace
from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as FigureCanvas
from matplotlib.backends.backend_wx import NavigationToolbar2Wx
from matplotlib.figure import Figure

class MyFrame(wx.Frame):
    def __init__(self):
        # Initializing the Frame
        wx.Frame.__init__(self, None, -1, title="", size=(600,500))
        #Create panel
        panel = wx.Panel(self)

        #Here we prepare the figure, canvas and axes object for the graph
        self.fig = Figure(figsize=(6,4), dpi=100)
        self.canvas = FigureCanvas(self, -1, self.fig)
        self.ax = self.fig.add_subplot(111, projection='polar')

        #Here, we borrow one example shown in the matplotlib gtk3 cookbook
        #and show a beautiful bar plot on a circular coordinate system
        self.theta = linspace(0.0, 2 * pi, 30, endpoint=False)
        self.radii = 10 * random.rand(30)
        self.plot_width = pi / 4 * random.rand(30)
        self.bars = self.ax.bar(self.theta, self.radii, width=self.plot_width, bottom=0.0)

        #Here defines the color of the bar, as well as setting it to be transparent
        for r, bar in zip(self.radii, self.bars):
            bar.set_facecolor(cm.jet(r / 10.))
            bar.set_alpha(0.5)
        #Here we generate the figure
        self.ax.plot()

        #Creating the vertical box of wxPython
        self.vbox = wx.BoxSizer(wx.VERTICAL)
        #Add canvas to the vertical box
        self.vbox.Add(self.canvas, wx.ALIGN_CENTER|wx.ALL, 1)
        #Add vertical box to the panel
        self.SetSizer(self.vbox)
        #Optimizing the size of the elements in vbox
        self.vbox.Fit(self)

#Every wxPython app is an instance of wx.App
app = wx.App()
#Here we create a wx.Frame() and specifying it as a top-level window
#by stating "None" as a parent object
frame = MyFrame()
#Show the frame!
frame.Show()
#Start the applciation's MainLoop, and ready for events handling
app.MainLoop()

输出:

我们已经展示了如何将 Matplotlib 图形嵌入到 GUI 中;然而,我们还没有展示 Matplotlib 和 wxWidgets 之间的交互。通过添加一个按钮并将一个函数绑定(Bind)到按钮上,可以轻松实现这一点。每次用户点击按钮时,这将更新图形。

让我们走一步通过点击按钮来更新图形的示例!尽管我们使用的是相同的图形,但底层的更新方法不同。这里我们将通过点击事件来更新图形,而不是如第六章中所示的自动计时器,在 Qt 5 中嵌入 Matplotlib

#Specifying that we are using WXAgg in matplotlib
import matplotlib
matplotlib.use('WXAgg')
#Here imports the wxPython and other supporting libraries
import wx, numpy, matplotlib.cm as cm, matplotlib.pyplot as plt
from numpy import arange, sin, pi, random, linspace
from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as FigureCanvas
from matplotlib.backends.backend_wx import NavigationToolbar2Wx
from matplotlib.figure import Figure

#This figure looks like it is from a radar, so we name the class radar
class Radar(wx.Frame):
    #Instantiation of Radar
    def __init__(self):
        # Initializing the Frame
        wx.Frame.__init__(self, None, -1, title="", size=(600,500))
        # Creating the panel
        panel = wx.Panel(self)
        #Setting up the figure, canvas and axes for drawing
        self.fig = Figure(figsize=(6,4), dpi=100)
        self.canvas = FigureCanvas(self, -1, self.fig)
        self.ax = self.fig.add_subplot(111, projection='polar')

        #Here comes the trick, create the button "Start Radar!"
        self.updateBtn = wx.Button(self, -1, "Start Radar!")
        #Bind the button with the clicking event, and invoke the update_fun function
        self.Bind(wx.EVT_BUTTON, self.update_fun, self.updateBtn)

        #Create the vertical box of Widgets
        self.vbox = wx.BoxSizer(wx.VERTICAL)
        #Add the canvas to the vertical box
        self.vbox.Add(self.canvas, wx.ALIGN_CENTER|wx.ALL, 1)
        #Add the button to the vertical box
        self.vbox.Add(self.updateBtn)
        #Add the vertical box to the Frame
        self.SetSizer(self.vbox)
        #Make sure the elements in the vertical box fits the figure size
        self.vbox.Fit(self)

    def update_fun(self,event):
        #Make sure we clear the figure each time before redrawing
        self.ax.cla()
        #updating the axes figure
        self.ax = self.fig.add_subplot(111, projection='polar')
        #Here, we borrow one example shown in the matplotlib gtk3 cookbook
        #and show a beautiful bar plot on a circular coordinate system
        self.theta = linspace(0.0, 2 * pi, 30, endpoint=False)
        self.radii = 10 * random.rand(30)
        self.plot_width = pi / 4 * random.rand(30)
        self.bars = self.ax.bar(self.theta, self.radii, width=self.plot_width, bottom=0.0)

        #Here defines the color of the bar, as well as setting it to be transparent
        for r, bar in zip(self.radii, self.bars):
            bar.set_facecolor(cm.jet(r / 10.))
            bar.set_alpha(0.5)

        #Here we draw on the canvas!
        self.fig.canvas.draw()
        #And print on terminal to make sure the function was invoked upon trigger
        print('Updating figure!')

#Every wxPython app is an instance of wx.App
app = wx.App(False)
#Here we create a wx.Frame() and specifying it as a top-level window
#by stating "None" as a parent object
frame = Radar()
#Show the frame!
frame.Show()
#Start the applciation's MainLoop, and ready for events handling
app.MainLoop()

输出:

通过点击“开始雷达!”按钮,我们调用update_fun函数,每次都会重新绘制一个新的图形。

在 wxGlade 中嵌入 Matplotlib 图形

对于非常简单的应用程序,GUI 界面比较有限的情况下,我们可以在应用程序源代码内部设计界面。一旦 GUI 变得更加复杂,这种方案就不可行,我们需要一个工具来支持我们进行 GUI 设计。wxWidgets 中最著名的工具之一就是 wxGlade。

wxGlade 是一个使用 wxPython 编写的界面设计程序,这使得它可以在所有支持这两个工具的平台上运行。

该理念与著名 GTK+图形用户界面设计工具 Glade 相似,外观和体验也非常相似。wxGlade 是一个帮助我们创建 wxWidgets 或 wxPython 用户界面的程序,但它并不是一个功能完整的代码编辑器;它只是一个设计器,生成的代码只是显示已创建的小部件。

尽管凤凰计划和 wxPython 4 相对较新,但它们都得到了 wxGlade 的支持。wxGlade 可以从 SourceForge 下载,用户可以轻松下载压缩包,解压缩并通过**python3**命令运行 wxGlade:

python3 wxglade.py

这里就是用户界面!

这里是图 5中三个主要窗口的细节。左上角的窗口是主要的调色板窗口。第一行的第一个按钮(Windows)是创建一个框架作为一切基础的按钮。左下角的窗口是属性窗口,它让我们显示和编辑应用程序、窗口和控件的属性。右边的窗口是树形窗口。它让我们可视化结构,允许编辑项目的结构,包括其应用程序、窗口、布局和控件。通过在树形窗口中选择一个项目,我们可以在属性窗口中编辑其对应的属性。

让我们点击按钮以 添加一个框架。接下来会出现一个小窗口:

选择基础类为 wxFrame;然后我们将在以下截图中生成一个 设计 窗口。从这里开始,我们可以点击并添加我们喜欢的按钮和功能:

首先,让我们为之前展示的代码创建一个容器。在点击任何按钮之前,我们先回顾一下前面 GUI 所需的基本元素:

  • 框架

  • 垂直框(wx.BoxSizer

  • 按钮

所以这非常简单;让我们点击 调色板 窗口中的 sizer 按钮,然后点击 设计 窗口:

从 树状窗口 中,我们可以看到 GUI 的结构。我们有一个包含两个插槽的框架。然而,我们希望使用垂直框而不是水平框;这可以在点击 sizer_2 时在 属性 窗口中进行修改:

现在让我们向插槽 2 添加一个按钮!这可以通过点击 调色板 窗口中的 按钮,然后点击 设计 窗口下部的插槽来完成。不过,从这里看,按钮的显示效果并不好。它位于下方面板的左侧。我们可以通过在 树状窗口中选择 _button1,并在 属性 窗口的布局选项卡中修改对齐方式来更改它。

在这里,我们选择了 wxEXPAND 和 wxALIGN_CENTER,这意味着它必须扩展并填充框架的宽度;这也确保它始终对齐到插槽的中心:

到目前为止,框架已经设置完成。让我们通过选择 文件 然后 生成代码来导出代码:

点击生成代码后,文件将保存在所选文件夹中(即用户保存 wxWidget 文件的文件夹),以下是一个代码片段:

#!/usr/bin/env python
# -*- coding: UTF-8 -*-
#
# generated by wxGlade 0.8.0 on Sun Apr  8 20:35:42 2018
#

import wx

# begin wxGlade: dependencies
# end wxGlade

# begin wxGlade: extracode
# end wxGlade

class MyFrame(wx.Frame):
    def __init__(self, *args, **kwds):
        # begin wxGlade: MyFrame.__init__
        kwds["style"] = kwds.get("style", 0) | wx.DEFAULT_FRAME_STYLE
        wx.Frame.__init__(self, *args, **kwds)
        self.SetSize((500, 550))
        self.button_1 = wx.Button(self, wx.ID_ANY, "button_1")

        self.__set_properties()
        self.__do_layout()
        # end wxGlade

    def __set_properties(self):
        # begin wxGlade: MyFrame.__set_properties
        self.SetTitle("frame")
        # end wxGlade

    def __do_layout(self):
        # begin wxGlade: MyFrame.__do_layout
        sizer_2 = wx.BoxSizer(wx.VERTICAL)
        sizer_2.Add((0, 0), 0, 0, 0)
        sizer_2.Add(self.button_1, 0, wx.ALIGN_CENTER | wx.EXPAND, 0)
        self.SetSizer(sizer_2)
        self.Layout()
        # end wxGlade

# end of class MyFrame

class MyApp(wx.App):
    def OnInit(self):
        self.frame = MyFrame(None, wx.ID_ANY, "")
        self.SetTopWindow(self.frame)
        self.frame.Show()
        return True

# end of class MyApp

if __name__ == "__main__":
    app = MyApp(0)
    app.MainLoop()

上述代码提供了一个独立的图形界面。然而,它缺少一些关键功能来使一切正常工作。让我们快速扩展一下,看看它是如何工作的:

import matplotlib
matplotlib.use('WXAgg')

import wx, numpy, matplotlib.cm as cm, matplotlib.pyplot as plt
from numpy import arange, sin, pi, random, linspace
from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as FigureCanvas
from matplotlib.backends.backend_wx import NavigationToolbar2Wx
from matplotlib.figure import Figure

class MyFrame(wx.Frame):
    def __init__(self, *args, **kwds):
        # begin wxGlade: MyFrame.__init__
        kwds["style"] = kwds.get("style", 0) | wx.DEFAULT_FRAME_STYLE
        wx.Frame.__init__(self, *args, **kwds)
        self.SetSize((500, 550))
        self.button_1 = wx.Button(self, wx.ID_ANY, "button_1")
##Code being added***
        self.Bind(wx.EVT_BUTTON, self.__updat_fun, self.button_1)
        #Setting up the figure, canvas and axes
        self.fig = Figure(figsize=(5,5), dpi=100)
        self.canvas = FigureCanvas(self, -1, self.fig)
        self.ax = self.fig.add_subplot(111, projection='polar')
        ##End of Code being added***self.__set_properties()
        self.__do_layout()
        # end wxGlade

    def __set_properties(self):
        # begin wxGlade: MyFrame.__set_properties
        self.SetTitle("frame")
        # end wxGlade

    def __do_layout(self):
        # begin wxGlade: MyFrame.__do_layout
        sizer_2 = wx.BoxSizer(wx.VERTICAL)
        sizer_2.Add(self.canvas, 0, wx.ALIGN_CENTER|wx.ALL, 1)
        sizer_2.Add(self.button_1, 0, wx.ALIGN_CENTER | wx.EXPAND, 0)
        self.SetSizer(sizer_2)
        self.Layout()
        # end wxGlade
##The udpate_fun that allows the figure to be updated upon clicking
##The __ in front of the update_fun indicates that it is a private function in Python syntax
    def __updat_fun(self,event):
        self.ax.cla()
        self.ax = self.fig.add_subplot(111, projection='polar')
        #Here, we borrow one example shown in the matplotlib gtk3 cookbook
        #and show a beautiful bar plot on a circular coordinate system
        self.theta = linspace(0.0, 2 * pi, 30, endpoint=False)
        self.radii = 10 * random.rand(30)
        self.plot_width = pi / 4 * random.rand(30)
        self.bars = self.ax.bar(self.theta, self.radii, width=self.plot_width, bottom=0.0)

        #Here defines the color of the bar, as well as setting it to be transparent
        for r, bar in zip(self.radii, self.bars):
            bar.set_facecolor(cm.jet(r / 10.))
            bar.set_alpha(0.5)

        self.fig.canvas.draw()
        print('Updating figure!')

# end of class MyFrame class MyApp(wx.App):
    def OnInit(self):
        self.frame = MyFrame(None, wx.ID_ANY, "")
        self.SetTopWindow(self.frame)
        self.frame.Show()
        return True

# end of class MyApp

if __name__ == "__main__":
    app = MyApp(0)
    app.MainLoop()

总结

现在,我们可以开发 wxWidgets 应用程序,并将 Matplotlib 嵌入其中。具体来说,读者应该能够在 wxFrame 中嵌入 Matplotlib 图形,使用 sizer 将图形和导航工具栏都嵌入到 wxFrame 中,通过交互更新图表,并使用 wxGlade 设计一个用于 Matplotlib 嵌入的 GUI。

我们现在准备继续前进,看看如何将 Matplotlib 集成到网页中。

第八章:将 Matplotlib 与 Web 应用程序集成

基于 Web 的应用程序(Web 应用)具有多重优势。首先,用户可以跨平台享受统一的体验。其次,由于无需安装过程,用户可以享受更简化的工作流。最后,从开发者的角度来看,开发周期可以简化,因为需要维护的特定平台代码较少。鉴于这些优势,越来越多的应用程序正在在线开发。

由于 Python 的流行和灵活性,Web 开发者使用基于 Python 的 Web 框架(如 Django 和 Flask)开发 Web 应用程序是有道理的。事实上,根据 hotframeworks.com/ 的数据,Django 和 Flask 分别在 175 个框架中排名第 6 和第 13。这些框架是 功能齐全的。从用户身份验证、用户管理、内容管理到 API 设计,它们都提供了完整的解决方案。代码库经过开源社区的严格审查,因此使用这些框架开发的网站可以防御常见攻击,如 SQL 注入、跨站请求伪造和跨站脚本攻击。

在本章中,我们将学习如何开发一个简单的网站,展示比特币的价格。将介绍基于 Django 的示例。我们将使用 Docker 18.03.0-ce 和 Django 2.0.4 进行演示。首先,我们将通过初始化基于 Docker 的开发环境的步骤来开始。

安装 Docker

Docker 允许开发者在自包含且轻量级的容器中运行应用程序。自 2013 年推出以来,Docker 迅速在开发者中获得了广泛的关注。在其技术的核心,Docker 使用 Linux 内核的资源隔离方法,而不是完整的虚拟化监控程序来运行应用程序。

这使得代码的开发、打包、部署和管理变得更加简便。因此,本章中的所有代码开发工作将基于 Docker 环境进行。

Docker for Windows 用户

在 Windows 上安装 Docker 有两种方式:名为 Docker for Windows 的包和 Docker Toolbox。我推荐使用稳定版本的 Docker Toolbox,因为 Docker for Windows 需要在 64 位 Windows 10 Pro 中支持 Hyper-V。同时,Docker for Windows 不支持较旧版本的 Windows。详细的安装说明可以在 docs.docker.com/toolbox/toolbox_install_windows/ 中找到,但我们也将在这里介绍一些重要步骤。

首先,从以下链接下载 Docker Toolbox:github.com/docker/toolbox/releases。选择名为 DockerToolbox-xx.xx.x-ce.exe 的文件,其中 x 表示最新版本号:

接下来,运行下载的安装程序。按照每个提示的默认说明进行安装:

Windows 可能会询问你是否允许进行某些更改,这是正常的,确保你允许这些更改发生。

最后,一旦安装完成,你应该能够在开始菜单中找到 Docker Quickstart Terminal:

点击图标启动 Docker Toolbox 终端,这将开始初始化过程。当该过程完成时,将显示以下终端:

Mac 用户的 Docker

对于 Mac 用户,我推荐 Docker CE for Mac(稳定版)应用程序,可以在 store.docker.com/editions/community/docker-ce-desktop-mac 下载。此外,完整的安装指南可以通过以下链接找到:docs.docker.com/docker-for-mac/install/

Docker CE for Mac 的安装过程可能比 Windows 版本更简单。以下是主要步骤:

  1. 首先,双击下载的 Docker.dmg 文件以挂载映像。当你看到以下弹窗时,将左侧的 Docker 图标拖动到右侧的应用程序文件夹中:

  1. 接下来,在你的应用程序文件夹或启动台中,找到并双击 Docker 应用程序。如果 Docker 启动成功,你应该能够在顶部状态栏看到一个鲸鱼图标:

  1. 最后,打开应用程序 | 实用工具文件夹中的终端应用程序。键入 docker info,然后按 Enter 键检查 Docker 是否正确安装:

更多关于 Django

Django 是一个流行的 web 框架,旨在简化 web 应用程序的开发和部署。它包括大量的模板代码,处理日常任务,如数据库模型管理、前端模板、会话认证和安全性。Django 基于 模型-模板-视图MTV)设计模式构建。

模型可能是 MTV 中最关键的组件。它指的是如何通过不同的表格和属性来表示你的数据。它还将不同数据库引擎的细节抽象化,使得相同的模型可以应用于 SQLite、MySQL 和 PostgreSQL。同时,Django 的模型层会暴露特定于引擎的参数,如 PostgreSQL 中的 ArrayFieldJSONField,用于微调数据表示。

模板类似于经典 MTV 框架中视图的作用。它处理数据的展示给用户。换句话说,它不涉及数据是如何生成的逻辑。

Django 中的视图负责处理用户请求,并返回相应的逻辑。它位于模型层和模板层之间。视图决定应该从模型中提取何种数据,以及如何处理数据以供模板使用。

Django 的主要卖点如下:

  • 开发速度:提供了大量的关键组件;这减少了开发周期中的重复任务。例如,使用 Django 构建一个简单的博客只需几分钟。

  • 安全性:Django 包含了 Web 安全的最佳实践。SQL 注入、跨站脚本、跨站请求伪造和点击劫持等黑客攻击的风险大大降低。其用户认证系统使用 PBKDF2 算法和加盐的 SHA256 哈希,这是 NIST 推荐的。其他先进的哈希算法,如 Argon2,也可用。

  • 可扩展性:Django 的 MTV 层使用的是无共享架构。如果某一层成为 Web 应用程序的瓶颈,只需增加更多硬件;Django 将利用额外的硬件来支持每一层。

在 Docker 容器中进行 Django 开发

为了保持整洁,让我们创建一个名为Django的空目录来托管所有文件。在Django目录内,我们需要使用我们喜欢的文本编辑器创建一个Dockerfile来定义容器的内容。Dockerfile定义了容器的基础镜像以及编译镜像所需的命令。

欲了解更多有关 Dockerfile 的信息,请访问docs.docker.com/engine/reference/builder/

我们将使用 Python 3.6.5 作为基础镜像。请将以下代码复制到您的 Dockerfile 中。一系列附加命令定义了工作目录和初始化过程:

# The official Python 3.6.5 runtime is used as the base image
FROM python:3.6.5-slim
# Disable buffering of output streams
ENV PYTHONUNBUFFERED 1
# Create a working directory within the container
RUN mkdir /app
WORKDIR /app
# Copy files and directories in the current directory to the container
ADD . /app/
# Install Django and other dependencies
RUN pip install -r requirements.txt

如您所见,我们还需要一个文本文件requirements.txt,以定义项目中的任何包依赖。请将以下内容添加到项目所在文件夹中的requirements.txt文件中:

Django==2.0.4
Matplotlib==2.2.2
stockstats==0.2.0
seaborn==0.8.1

现在,我们可以在终端中运行docker build -t django来构建镜像。构建过程可能需要几分钟才能完成:

在运行命令之前,请确保您当前位于相同的项目文件夹中。

如果构建过程完成,将显示以下消息。Successfully built ...消息结尾的哈希码可能会有所不同:

Successfully built 018e75992e59
Successfully tagged django:latest

启动一个新的 Django 站点

我们现在将使用docker run命令创建一个新的 Docker 容器。-v "$(pwd)":/app参数创建了当前目录到容器内/app的绑定挂载。当前目录中的文件将在主机和客机系统之间共享。

第二个未标记的参数 django 定义了用于创建容器的映像。命令字符串的其余部分如下:

django django-admin startproject --template=https://github.com/arocks/edge/archive/master.zip --extension=py,md,html,env crypto_stats

这被传递给客户机容器以执行。它使用 Arun Ravindran 的边缘模板 (django-edge.readthedocs.io/en/latest/) 创建了一个名为 crypto_stats 的新 Django 项目:

docker run -v "$(pwd)":/app django django-admin startproject --template=https://github.com/arocks/edge/archive/master.zip --extension=py,md,html,env crypto_stats

成功执行后,如果您进入新创建的 crypto_stats 文件夹,您应该能看到以下文件和目录:

Django 依赖项的安装

crypto_stats 文件夹中的 requirements.txt 文件定义了我们的 Django 项目的 Python 包依赖关系。要安装这些依赖项,请执行以下 docker run 命令。

参数 -p 8000:8000 将端口 8000 从客户机暴露给主机机器。参数 -it 创建一个支持 stdin 的伪终端,以允许交互式终端会话。

我们再次使用 django 映像,但这次我们启动了一个 Bash 终端 shell:

docker run -v "$(pwd)":/app -p 8000:8000 -it django bash
cd crypto_stats
pip install -r requirements.txt

在执行命令时,请确保您仍然位于项目的根目录(即 Django)中。

命令链将产生以下结果:

Django 环境设置

敏感的环境变量,例如 Django 的 SECRET_KEY (docs.djangoproject.com/en/2.0/ref/settings/#std:setting-SECRET_KEY),应该保存在一个从版本控制软件中排除的私有文件中。为简单起见,我们可以直接使用项目模板中的示例:

cd src
cp crypto_stats/settings/local.sample.env crypto_stats/settings/local.env

接下来,我们可以使用 manage.py 来创建一个默认的 SQLite 数据库和超级用户:

python manage.py migrate
python manage.py createsuperuser

migrate 命令初始化数据库模型,包括用户认证、管理员、用户配置文件、用户会话、内容类型和缩略图。

createsuperuser 命令将询问您一系列问题以创建超级用户:

运行开发服务器

启动默认的开发服务器非常简单;实际上,只需一行代码:

python manage.py runserver 0.0.0.0:8000

参数 0.0.0.0:8000 将告诉 Django 在端口 8000 上为所有地址提供网站服务。

在您的主机上,您现在可以启动您喜欢的浏览器,并访问 http://localhost:8000 查看您的网站:

网站的外观还不错,是吗?

使用 Django 和 Matplotlib 显示比特币价格

现在,我们仅使用几个命令就建立了一个完整的网站框架。希望您能欣赏使用 Django 进行网页开发的简便性。现在,我将演示如何将 Matplotlib 图表集成到 Django 网站中,这是本章的关键主题。

创建一个 Django 应用程序

Django 生态系统中的一个应用指的是在网站中处理特定功能的应用程序。例如,我们的默认项目已经包含了 profile 和 account 应用程序。澄清了术语后,我们准备构建一个显示比特币最新价格的应用。

我们应该让开发服务器在后台运行。当服务器检测到代码库的任何更改时,它将自动重新加载以反映更改。因此,现在我们需要启动一个新的终端并连接到正在运行的服务器容器:

docker exec -it 377bfb2f3db4 bash

bash前面的那些看起来很奇怪的数字是容器的 ID。我们可以从持有正在运行的服务器的终端中找到该 ID:

或者,我们可以通过发出以下命令来获取所有正在运行的容器的 ID:

docker ps -a

docker exec命令帮助你返回到与开发服务器相同的 Bash 环境。我们现在可以启动一个新应用:

cd /app/crypto_stats/src
python manage.py startapp bitcoin

在主机计算机的项目目录中,我们应该能够看到crypto_stats/src/下的新bitcoin文件夹:

创建一个简单的 Django 视图

我将通过一个简单的折线图演示创建 Django 视图的工作流程。

在新创建的比特币应用文件夹中,你应该能够找到views.py,它存储了应用中的所有视图。让我们编辑它并创建一个输出 Matplotlib 折线图的视图:

from django.shortcuts import render
from django.http import HttpResponse

# Create your views here.
from io import BytesIO
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt

def test_view(request):
    # Create a new Matplotlib figure
    fig, ax = plt.subplots()

    # Prepare a simple line chart
    ax.plot([1, 2, 3, 4], [3, 6, 9, 12])

    ax.set_title('Matplotlib Chart in Django') 

    plt.tight_layout()

    # Create a bytes buffer for saving image
    fig_buffer = BytesIO()
    plt.savefig(fig_buffer, dpi=150)

    # Save the figure as a HttpResponse
    response = HttpResponse(content_type='image/png')
    response.write(fig_buffer.getvalue())
    fig_buffer.close()

    return response

由于我们的服务器容器中没有 Tkinter,我们需要通过首先调用matplotlib.use('Agg')来将 Matplotlib 图形后端从默认的 TkAgg 切换到 Agg。

matplotlib.use('Agg')必须在import matplotlib之后,并且在调用任何 Matplotlib 函数之前立即调用。

函数test_view(request)期望一个 Django HttpRequest对象(docs.djangoproject.com/en/2.0/ref/request-response/#django.http.HttpRequest)作为输入,并输出一个 Django HttpResponse对象(docs.djangoproject.com/en/2.0/ref/request-response/#django.http.HttpResponse)。

为了将 Matplotlib 图表导入到HttpResponse对象中,我们需要先将图表保存到一个中间的BytesIO对象中,该对象可以在io包中找到(docs.python.org/3/library/io.html#binary-i-o)。BytesIO对象充当二进制图像文件的缓冲区,以便plt.savefig能够直接将 PNG 文件写入其中。

接下来,我们创建一个新的HttpResponse()对象,并将content_type参数设置为image/png。缓冲区中的二进制内容通过response.write(fig_buffer.getvalue())导出到HttpResponse()对象中。最后,关闭缓冲区以释放临时内存。

为了将用户引导到这个视图,我们需要在{Project_folder}/crypto_stats/src/bitcoin文件夹内创建一个名为urls.py的新文件。

from django.urls import path

from . import views

app_name = 'bitcoin'
urlpatterns = [
    path('test/', views.test_view),
]

这一行path('test/', views.test_view)表示所有以test/结尾的 URL 将被定向到test_view

我们还需要将应用的url模式添加到全局模式中。让我们编辑{Project_folder}/crypto_stats/src/crypto_stats/urls.py,并添加以下两行注释:

...
import profiles.urls
import accounts.urls
# Import your app's url patterns here
import bitcoin.urls
from . import views

...

urlpatterns = [
    path('', views.HomePage.as_view(), name='home'),
    path('about/', views.AboutPage.as_view(), name='about'),
    path('users/', include(profiles.urls)),
    path('admin/', admin.site.urls),
    # Add your app's url patterns here
    path('bitcoin/', include(bitcoin.urls)),
    path('', include(accounts.urls)),
]
...

这一行path('bitcoin/', include(bitcoin.urls)),表示所有以<your-domain>/bitcoin开头的 URL 将被定向到比特币应用。

等待几秒钟直到开发服务器重新加载。现在,你可以前往localhost:8000/bitcoin/test/查看你的图表。

创建比特币 K 线图视图

在这一部分,我们将从 Quandl API 获取比特币的历史价格。请注意,我们无法保证所展示的可视化数据的准确性、完整性或有效性;也不对可能发生的任何错误或遗漏负责。数据、可视化和分析仅以现状提供,仅供教育用途,且不提供任何形式的保证。建议读者在做出投资决策之前,先进行独立的个别加密货币研究。

如果你不熟悉 Quandl,它是一个金融和经济数据仓库,存储着来自数百个发布者的数百万数据集。在使用 Quandl API 之前,你需要在其网站上注册一个账户(www.quandl.com)。可以通过以下链接的说明获取免费的 API 访问密钥:docs.quandl.com/docs#section-authentication。在下一章我会介绍更多关于 Quandl 和 API 的内容。

现在,删除crypto_stats/src/bitcoin文件夹中的现有views.py文件。从本章的代码库中将views1.py复制到crypto_stats/src/bitcoin,并将其重命名为views.py。我会相应地解释views1.py中的每一部分。

在 Bitstamp 交易所的比特币历史价格数据可以在此找到:www.quandl.com/data/BCHARTS/BITSTAMPUSD-Bitcoin-Markets-bitstampUSD。我们目标数据集的唯一标识符是BCHARTS/BITSTAMPUSD。尽管 Quandl 提供了官方的 Python 客户端库,我们为了演示导入 JSON 数据的一般流程,将不使用该库。get_bitcoin_dataset函数仅使用urllib.request.urlopenjson.loads来从 API 获取 JSON 数据。最后,数据被处理为 pandas DataFrame,以供进一步使用。

... A bunch of import statements

def get_bitcoin_dataset():
    """Obtain and parse a quandl bitcoin dataset in Pandas DataFrame     format
     Quandl returns dataset in JSON format, where data is stored as a 
     list of lists in response['dataset']['data'], and column headers
     stored in response['dataset']['column_names'].

     Returns:
     df: Pandas DataFrame of a Quandl dataset"""

    # Input your own API key here
    api_key = ""

    # Quandl code for Bitcoin historical price in BitStamp exchange
    code = "BCHARTS/BITSTAMPUSD"
    base_url = "https://www.quandl.com/api/v3/datasets/"
    url_suffix = ".json?api_key="

    # We want to get the data within a one-year window only
    time_now = datetime.datetime.now()
    one_year_ago = time_now.replace(year=time_now.year-1)
    start_date = one_year_ago.date().isoformat()
    end_date = time_now.date().isoformat()
    date = "&start_date={}&end_date={}".format(start_date, end_date)

    # Fetch the JSON response 
    u = urlopen(base_url + code + url_suffix + api_key + date)
    response = json.loads(u.read().decode('utf-8'))

    # Format the response as Pandas Dataframe
    df = pd.DataFrame(response['dataset']['data'], columns=response['dataset']['column_names'])

    # Convert Date column from string to Python datetime object,
    # then to float number that is supported by Matplotlib.
    df["Datetime"] = date2num(pd.to_datetime(df["Date"], format="%Y-%m-%d").tolist())

    return df

记得在这一行指定你自己的 API 密钥:api_key = ""

df中的Date列是作为一系列 Python 字符串记录的。尽管 Seaborn 可以在某些函数中使用字符串格式的日期,Matplotlib 却不行。为了使日期能够进行数据处理和可视化,我们需要将其转换为 Matplotlib 支持的浮动点数。因此,我使用了matplotlib.dates.date2num来进行转换。

我们的数据框包含每个交易日的开盘价和收盘价,以及最高价和最低价。到目前为止,我们描述的所有图表都无法在一个图表中描述所有这些变量的趋势。

在金融世界中,蜡烛图几乎是描述股票、货币和商品在一段时间内价格变动的默认选择。每根蜡烛由描述开盘价和收盘价的主体,以及展示最高价和最低价的延伸蜡烛线组成,表示某一特定交易日。如果收盘价高于开盘价,蜡烛通常为黑色。相反,如果收盘价低于开盘价,蜡烛则为红色。交易者可以根据颜色和蜡烛主体的边界来推断开盘价和收盘价。

在下面的示例中,我们将准备一个比特币在过去 30 个交易日的数据框中的蜡烛图。candlestick_ohlc函数是从已废弃的matplotlib.finance包中改编而来。它绘制时间、开盘价、最高价、最低价和收盘价为一个从低到高的垂直线。它进一步使用一系列彩色矩形条来表示开盘和收盘之间的跨度。

def candlestick_ohlc(ax, quotes, width=0.2, colorup='k', colordown='r',
 alpha=1.0):
    """
    Parameters
    ----------
    ax : `Axes`
    an Axes instance to plot to
    quotes : sequence of (time, open, high, low, close, ...) sequences
    As long as the first 5 elements are these values,
    the record can be as long as you want (e.g., it may store volume).
    time must be in float days format - see date2num
    width : float
        fraction of a day for the rectangle width
    colorup : color
        the color of the rectangle where close >= open
    colordown : color
        the color of the rectangle where close < open
    alpha : float
        the rectangle alpha level
    Returns
    -------
    ret : tuple
        returns (lines, patches) where lines is a list of lines
        added and patches is a list of the rectangle patches added
    """
    OFFSET = width / 2.0
    lines = []
    patches = []
    for q in quotes:
        t, open, high, low, close = q[:5]
        if close >= open:
            color = colorup
            lower = open
            height = close - open
        else:
            color = colordown
            lower = close
            height = open - close

        vline = Line2D(
                   xdata=(t, t), ydata=(low, high),
                   color=color,
                   linewidth=0.5,
                   antialiased=True,
        )
        rect = Rectangle(
                     xy=(t - OFFSET, lower),
                     width=width,
                     height=height,
                     facecolor=color,
                     edgecolor=color,
        )
        rect.set_alpha(alpha)
        lines.append(vline)
        patches.append(rect)
        ax.add_line(vline)
        ax.add_patch(rect)
        ax.autoscale_view()

    return lines, patches

bitcoin_chart函数处理用户请求的实际处理和HttpResponse的输出。

def bitcoin_chart(request):
    # Get a dataframe of bitcoin prices
    bitcoin_df = get_bitcoin_dataset()

    # candlestick_ohlc expects Date (in floating point number), Open, High, Low, Close columns only
    # So we need to select the useful columns first using DataFrame.loc[]. Extra columns can exist, 
    # but they are ignored. Next we get the data for the last 30 trading only for simplicity of plots.
    candlestick_data = bitcoin_df.loc[:, ["Datetime",
                                          "Open",
                                          "High",
                                          "Low",
                                          "Close",
                                          "Volume (Currency)"]].iloc[:30]

    # Create a new Matplotlib figure
    fig, ax = plt.subplots()

    # Prepare a candlestick plot
    candlestick_ohlc(ax, candlestick_data.values, width=0.6)

    ax.xaxis.set_major_locator(WeekdayLocator(MONDAY)) # major ticks on the mondays
    ax.xaxis.set_minor_locator(DayLocator()) # minor ticks on the days
    ax.xaxis.set_major_formatter(DateFormatter('%Y-%m-%d'))
    ax.xaxis_date() # treat the x data as dates

    # rotate all ticks to vertical
    plt.setp(ax.get_xticklabels(), rotation=90, horizontalalignment='right') 

    ax.set_ylabel('Price (US $)') # Set y-axis label

    plt.tight_layout()

    # Create a bytes buffer for saving image
    fig_buffer = BytesIO()
    plt.savefig(fig_buffer, dpi=150)

    # Save the figure as a HttpResponse
    response = HttpResponse(content_type='image/png')
    response.write(fig_buffer.getvalue())
    fig_buffer.close()

    return response

ax.xaxis.set_major_formatter(DateFormatter('%Y-%m-%d')) 对于将浮动点数转换回日期非常有用。

与第一个 Django 视图示例类似,我们需要修改urls.py,将 URL 指向我们的bitcoin_chart视图。

from django.urls import path

from . import views

app_name = 'bitcoin'
urlpatterns = [
    path('30/', views.bitcoin_chart),
]

完成!你可以通过访问http://localhost:8000/bitcoin/30/查看比特币蜡烛图。

集成更多的价格指标

当前形式的蜡烛图有些单调。交易者通常会叠加股票指标,如平均真实范围ATR)、布林带、商品通道指数CCI)、指数移动平均线EMA)、平滑异同移动平均线MACD)、相对强弱指数RSI)等,用于技术分析。

Stockstats (github.com/jealous/stockstats) 是一个很棒的包,可以用来计算前面提到的指标/统计数据以及更多内容。它基于 pandas DataFrame,并在访问时动态生成这些统计数据。

在这一部分,我们可以通过stockstats.StockDataFrame.retype()将一个 pandas DataFrame 转换为一个 stockstats DataFrame。然后,可以通过遵循StockDataFrame["variable_timeWindow_indicator"]的模式访问大量的股票指标。例如,StockDataFrame['open_2_sma']会给我们开盘价的 2 日简单移动平均。某些指标可能有快捷方式,因此请参考官方文档获取更多信息。

我们代码库中的views2.py文件包含了创建扩展比特币定价视图的代码。你可以将本章代码库中的views2.py复制到crypto_stats/src/bitcoin目录,并将其重命名为views.py

下面是我们之前代码中需要的重要更改:

# FuncFormatter to convert tick values to Millions
def millions(x, pos):
    return '%dM' % (x/1e6)

def bitcoin_chart(request):
    # Get a dataframe of bitcoin prices
    bitcoin_df = get_bitcoin_dataset()

    # candlestick_ohlc expects Date (in floating point number), Open, High, Low, Close columns only
    # So we need to select the useful columns first using DataFrame.loc[]. Extra columns can exist, 
    # but they are ignored. Next we get the data for the last 30 trading only for simplicity of plots.
    candlestick_data = bitcoin_df.loc[:, ["Datetime",
                                          "Open",
                                          "High",
                                          "Low",
                                          "Close",
                                          "Volume (Currency)"]].iloc[:30]

    # Convert to StockDataFrame
    # Need to pass a copy of candlestick_data to StockDataFrame.retype
    # Otherwise the original candlestick_data will be modified
    stockstats = StockDataFrame.retype(candlestick_data.copy())

    # 5-day exponential moving average on closing price
    ema_5 = stockstats["close_5_ema"]
    # 10-day exponential moving average on closing price
    ema_10 = stockstats["close_10_ema"]
    # 30-day exponential moving average on closing price
    ema_30 = stockstats["close_30_ema"]
    # Upper Bollinger band
    boll_ub = stockstats["boll_ub"]
    # Lower Bollinger band
    boll_lb = stockstats["boll_lb"]
    # 7-day Relative Strength Index
    rsi_7 = stockstats['rsi_7']
    # 14-day Relative Strength Index
    rsi_14 = stockstats['rsi_14']

    # Create 3 subplots spread across three rows, with shared x-axis. 
    # The height ratio is specified via gridspec_kw
    fig, axarr = plt.subplots(nrows=3, ncols=1, sharex=True, figsize=(8,8),
                             gridspec_kw={'height_ratios':[3,1,1]})

    # Prepare a candlestick plot in the first axes
    candlestick_ohlc(axarr[0], candlestick_data.values, width=0.6)

    # Overlay stock indicators in the first axes
    axarr[0].plot(candlestick_data["Datetime"], ema_5, lw=1, label='EMA (5)')
    axarr[0].plot(candlestick_data["Datetime"], ema_10, lw=1, label='EMA (10)')
    axarr[0].plot(candlestick_data["Datetime"], ema_30, lw=1, label='EMA (30)')
    axarr[0].plot(candlestick_data["Datetime"], boll_ub, lw=2, linestyle="--", label='Bollinger upper')
    axarr[0].plot(candlestick_data["Datetime"], boll_lb, lw=2, linestyle="--", label='Bollinger lower')

    # Display RSI in the second axes
    axarr[1].axhline(y=30, lw=2, color = '0.7') # Line for oversold threshold
    axarr[1].axhline(y=50, lw=2, linestyle="--", color = '0.8') # Neutral RSI
    axarr[1].axhline(y=70, lw=2, color = '0.7') # Line for overbought threshold
    axarr[1].plot(candlestick_data["Datetime"], rsi_7, lw=2, label='RSI (7)')
    axarr[1].plot(candlestick_data["Datetime"], rsi_14, lw=2, label='RSI (14)')

    # Display trade volume in the third axes
    axarr[2].bar(candlestick_data["Datetime"], candlestick_data['Volume (Currency)'])

    # Label the axes
    axarr[0].set_ylabel('Price (US $)')
    axarr[1].set_ylabel('RSI')
    axarr[2].set_ylabel('Volume (US $)')

    axarr[2].xaxis.set_major_locator(WeekdayLocator(MONDAY)) # major ticks on the mondays
    axarr[2].xaxis.set_minor_locator(DayLocator()) # minor ticks on the days
    axarr[2].xaxis.set_major_formatter(DateFormatter('%Y-%m-%d'))
    axarr[2].xaxis_date() # treat the x data as dates
    axarr[2].yaxis.set_major_formatter(FuncFormatter(millions)) # Change the y-axis ticks to millions
    plt.setp(axarr[2].get_xticklabels(), rotation=90, horizontalalignment='right') # Rotate x-tick labels by 90 degree

    # Limit the x-axis range to the last 30 days
    time_now = datetime.datetime.now()
    datemin = time_now-datetime.timedelta(days=30)
    datemax = time_now
    axarr[2].set_xlim(datemin, datemax)

    # Show figure legend
    axarr[0].legend()
    axarr[1].legend()

    # Show figure title
    axarr[0].set_title("Bitcoin 30-day price trend", loc='left')

    plt.tight_layout()

    # Create a bytes buffer for saving image
    fig_buffer = BytesIO()
    plt.savefig(fig_buffer, dpi=150)

    # Save the figure as a HttpResponse
    response = HttpResponse(content_type='image/png')
    response.write(fig_buffer.getvalue())
    fig_buffer.close()

    return response

再次提醒,请确保在get_bitcoin_dataset()函数中的代码行内指定你自己的 API 密钥:api_key = ""

修改后的bitcoin_chart视图将创建三个子图,它们跨越三行,并共享一个x轴。子图之间的高度比通过gridspec_kw进行指定。

第一个子图将显示蜡烛图以及来自stockstats包的各种股票指标。

第二个子图显示了比特币在 30 天窗口中的相对强弱指数RSI)。

最后,第三个子图显示了比特币的交易量(美元)。自定义的FuncFormatter millions被用来将y轴的值转换为百万。

你现在可以访问相同的链接localhost:8000/bitcoin/30/来查看完整的图表。

将图像集成到 Django 模板中

要在首页显示图表,我们可以修改位于{Project_folder}/crypto_stats/src/templates/home.html的首页模板。

我们需要修改<!-- Benefits of the Django application -->注释后的代码行,修改为以下内容:

{% block container %}
<!-- Benefits of the Django application -->
<a name="about"></a>

<div class="container">
  <div class="row">
    <div class="col-lg-8">
      <h2>Bitcoin pricing trend</h2>
      <img src="img/" alt="Bitcoin prices" style="width:100%">
      <p><a class="btn btn-primary" href="#" role="button">View details &raquo;</a></p>
    </div>
    <div class="col-lg-4">
      <h2>Heading</h2>
      <p>Donec sed odio dui. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Vestibulum id ligula porta felis euismod semper. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa.</p>
      <p><a class="btn btn-primary" href="#" role="button">View details &raquo;</a></p>
    </div>
  </div>
</div>

{% endblock container %}

基本上,我们的bitcoin_chart视图是通过<img src="img/" alt="Bitcoin prices" style="width:100%">这一行作为图像加载的。我还将容器部分的列数从 3 列减少到了 2 列,并通过将类设置为col-lg-8来调整了第一列的大小。

如果你访问首页(即http://localhost:8000),当你滚动到页面底部时,你会看到以下屏幕:

这个实现有一些注意事项。首先,每次访问页面都会触发一次 API 调用到 Quandl,因此你的免费 API 配额会很快被消耗。更好的方法是每天获取一次价格,并将数据记录到合适的数据库模型中。

其次,当前形式的图像输出并没有集成到特定的应用模板中。这超出了本书以 Matplotlib 为主题的范围。然而,感兴趣的读者可以参考在线文档中的说明(docs.djangoproject.com/en/2.0/topics/templates/)。

最后,这些图像是静态的。像mpld3和 Plotly 这样的第三方包可以将 Matplotlib 图表转换为基于 Javascript 的交互式图表。使用这些包可以进一步增强用户体验。

总结

在本章中,你了解了一个流行的框架,旨在简化 Web 应用程序的开发和部署,即 Django。你还进一步学习了如何将 Matplotlib 图表集成到 Django 网站中。

在下一章中,我们将介绍一些有用的技术,用于定制图形美学,以便有效讲述故事。

第九章:Matplotlib 在现实世界中的应用

到目前为止,我们希望你已经掌握了使用 Matplotlib 创建和定制图表的技巧。让我们在已有的基础上进一步深入,通过现实世界的例子开始我们的 Matplotlib 高级用法之旅。

首先,我们将介绍如何获取在线数据,这通常是通过 应用程序编程接口 (API) 或传统的网页抓取技术获得的。接下来,我们将探索如何将 Matplotlib 2.x 与 Python 中的其他科学计算包集成,用于不同数据类型的可视化。

常见的 API 数据格式

许多网站通过其 API 分发数据,API 通过标准化架构将应用程序连接起来。虽然我们在这里不会详细讨论如何使用 API,但我们会介绍最常见的 API 数据交换格式——CSV 和 JSON。

感兴趣的读者可以访问特定网站的文档,了解如何使用 API。

我们在第四章,高级 Matplotlib 中简要介绍了 CSV 文件的解析。为了帮助你更好地理解,我们将同时使用 CSV 和 JSON 来表示相同的数据。

CSV

逗号分隔值 (CSV) 是最早的文件格式之一,远在万维网存在之前就已被引入。然而,随着 JSON 和 XML 等先进格式的流行,CSV 正在逐渐被淘汰。顾名思义,数据值是通过逗号分隔的。预安装的 csv 包和 pandas 包都包含了读取和写入 CSV 格式数据的类。以下 CSV 示例定义了一个包含两个国家的 population(人口)表:

Country,Time,Sex,Age,Value
United Kingdom,1950,Male,0-4,2238.735
United States of America,1950,Male,0-4,8812.309

JSON

JavaScript 对象表示法 (JSON) 由于其高效性和简洁性,近年来越来越受欢迎。JSON 允许指定数字、字符串、布尔值、数组和对象。Python 提供了默认的 json 包来解析 JSON。或者,pandas.read_json 类可以用来将 JSON 导入为 pandas DataFrame。前述的人口表可以用 JSON 表示如下:

{
 "population": [
 {
 "Country": "United Kingdom",
 "Time": 1950,
 "Sex", "Male",
 "Age", "0-4",
 "Value",2238.735
 },{
 "Country": "United States of America",
 "Time": 1950,
 "Sex", "Male",
 "Age", "0-4",
 "Value",8812.309
 },
 ]
}

从 JSON API 导入和可视化数据

现在,让我们学习如何解析来自 Quandl API 的金融数据,以创建有价值的可视化图表。Quandl 是一个金融和经济数据仓库,存储了来自数百个发布者的数百万数据集。Quandl 的最大优点是,这些数据集通过统一的 API 提供,用户无需担心如何正确解析数据。匿名用户每天可以获得最多 50 次 API 调用,注册用户则可以获得最多 500 次免费 API 调用。读者可以在 www.quandl.com/?modal=register 上注册免费 API 密钥。

在 Quandl 中,每个数据集都有一个唯一的 ID,由每个搜索结果网页上的 Quandl 代码定义。例如,Quandl 代码GOOG/NASDAQ_SWTX定义了 Google Finance 发布的历史 NASDAQ 指数数据。每个数据集都提供三种不同的格式——CSV、JSON 和 XML。

尽管 Quandl 提供了官方的 Python 客户端库,我们不会使用它,而是为了演示从 API 导入 JSON 数据的通用流程。根据 Quandl 的文档,我们可以通过以下 API 调用获取 JSON 格式的数据表:

GET https://www.quandl.com/api/v3/datasets/{Quandl code}/data.json

首先,让我们尝试从 Quandl 获取大麦克指数数据。大麦克指数由经济学人于 1986 年发明,作为一种轻松的方式来判断货币是否处于正确的水平。它基于购买力平价PPP)理论,并被视为货币在购买力平价下的非正式汇率衡量标准。它通过将货币与一篮子类似的商品和服务进行比较来衡量其价值,在这种情况下是大麦克。市场汇率下的价格差异意味着某种货币被低估或高估:

from urllib.request import urlopen
import json
import time
import pandas as pd

def get_bigmac_codes():
    """Get a pandas DataFrame of all codes in the Big Mac index dataset

    The first column contains the code, while the second header
    contains the description of the code.

    E.g. 
    ECONOMIST/BIGMAC_ARG,Big Mac Index - Argentina
    ECONOMIST/BIGMAC_AUS,Big Mac Index - Australia
    ECONOMIST/BIGMAC_BRA,Big Mac Index - Brazil

    Returns:
        codes: pandas DataFrame of Quandl dataset codes"""

    codes_url = "https://www.quandl.com/api/v3/databases/ECONOMIST/codes"
    codes = pd.read_csv(codes_url, header=None, names=['Code', 'Description'], 
                        compression='zip', encoding='latin_1')

    return codes

def get_quandl_dataset(api_key, code):
    """Obtain and parse a quandl dataset in pandas DataFrame format

    Quandl returns dataset in JSON format, where data is stored as a 
    list of lists in response['dataset']['data'], and column headers
    stored in response['dataset']['column_names'].

    E.g. {'dataset': {...,
             'column_names': ['Date',
                              'local_price',
                              'dollar_ex',
                              'dollar_price',
                              'dollar_ppp',
                              'dollar_valuation',
                              'dollar_adj_valuation',
                              'euro_adj_valuation',
                              'sterling_adj_valuation',
                              'yen_adj_valuation',
                              'yuan_adj_valuation'],
             'data': [['2017-01-31',
                       55.0,
                       15.8575,
                       3.4683903515687,
                       10.869565217391,
                       -31.454736135007,
                       6.2671477203176,
                       8.2697553162259,
                       29.626894343348,
                       32.714616745128,
                       13.625825886047],
                      ['2016-07-31',
                       50.0,
                       14.935,
                       3.3478406427854,
                       9.9206349206349,
                       -33.574590420925,
                       2.0726096168216,
                       0.40224795003514,
                       17.56448458418,
                       19.76377270142,
                       11.643103380531]
                      ],
             'database_code': 'ECONOMIST',
             'dataset_code': 'BIGMAC_ARG',
             ... }}

    A custom column--country is added to denote the 3-letter country code.

    Args:
        api_key: Quandl API key
        code: Quandl dataset code

    Returns:
        df: pandas DataFrame of a Quandl dataset

    """
    base_url = "https://www.quandl.com/api/v3/datasets/"
    url_suffix = ".json?api_key="

    # Fetch the JSON response 
    u = urlopen(base_url + code + url_suffix + api_key)
    response = json.loads(u.read().decode('utf-8'))

    # Format the response as pandas Dataframe
    df = pd.DataFrame(response['dataset']['data'], columns=response['dataset']['column_names'])

    # Label the country code
    df['country'] = code[-3:]

    return df

quandl_dfs = []
codes = get_bigmac_codes()

# Replace this with your own API key
api_key = "INSERT-YOUR-KEY-HERE" 

for code in codes.Code:
    # Get the DataFrame of a Quandl dataset
    df = get_quandl_dataset(api_key, code)

    # Store in a list
    quandl_dfs.append(df)

    # Prevents exceeding the API speed limit
    time.sleep(2)

# Concatenate the list of data frames into a single one 
bigmac_df = pd.concat(quandl_dfs)
bigmac_df.head()

这是预期的结果,显示数据框的前五行:

 01234
Date31-07-1731-01-1731-07-1631-01-1631-07-15
local_price5.95.85.755.35.3
dollar_ex1.3030161.3566681.3357381.4157291.35126
dollar_price4.5279554.275184.3047373.7436553.922265
dollar_ppp1.1132081.1462451.1408731.0750511.106472
dollar_valuation-14.56689-15.510277-14.588542-24.06379-18.115553
dollar_adj_valuation-11.7012-11.9234-11.0236-28.1641-22.1691
euro_adj_valuation-13.0262-10.2636-12.4796-22.2864-18.573
sterling_adj_valuation2.584227.437712.48065-22.293-23.1926
yen_adj_valuation19.94179.996884.39776-4.00426.93893
yuan_adj_valuation-2.35772-5.82434-2.681-20.6755-14.1711
countryAUSAUSAUSAUSAUS

解析 Quandl API 中的 JSON 数据的代码有点复杂,因此需要额外的解释。第一个函数get_bigmac_codes()解析 Quandl 经济学人数据库中所有可用数据集代码的列表,并将其转换为 pandas DataFrame。同时,第二个函数get_quandl_dataset(api_key, code)将 Quandl 数据集 API 查询的 JSON 响应转换为 pandas DataFrame。所有获取的数据集通过pandas.concat()合并为一个单独的数据框。

我们应该记住,大麦克指数在不同国家之间并不直接可比。通常,我们会预期贫穷国家的商品价格低于富裕国家。为了更公平地展示指数,最好展示大麦克价格与国内生产总值GDP)人均之间的关系。

为了达到这一目的,我们将从 Quandl 的世界银行世界发展指标WWDI)数据库中获取 GDP 数据集。基于之前从 Quandl 获取 JSON 数据的代码示例,你能尝试将其修改为下载人均 GDP 数据集吗?

对于那些急于查看的用户,以下是完整的代码:

import urllib
import json
import pandas as pd
import time
from urllib.request import urlopen

def get_gdp_dataset(api_key, country_code):
    """Obtain and parse a quandl GDP dataset in pandas DataFrame format
    Quandl returns dataset in JSON format, where data is stored as a 
    list of lists in response['dataset']['data'], and column headers
    stored in response['dataset']['column_names'].

    Args:
        api_key: Quandl API key
        country_code: Three letter code to represent country

    Returns:
        df: pandas DataFrame of a Quandl dataset
    """
    base_url = "https://www.quandl.com/api/v3/datasets/"
    url_suffix = ".json?api_key="

    # Compose the Quandl API dataset code to get GDP per capita (constant 2000 US$) dataset
    gdp_code = "WWDI/" + country_code + "_NY_GDP_PCAP_KD"

   # Parse the JSON response from Quandl API
   # Some countries might be missing, so we need error handling code
   try:
       u = urlopen(base_url + gdp_code + url_suffix + api_key)
   except urllib.error.URLError as e:
       print(gdp_code,e)
       return None

   response = json.loads(u.read().decode('utf-8'))

   # Format the response as pandas Dataframe
   df = pd.DataFrame(response['dataset']['data'], columns=response['dataset']['column_names'])

   # Add a new country code column
   df['country'] = country_code

   return df

api_key = "INSERT-YOUR-KEY-HERE" #Change this to your own API key

quandl_dfs = []

# Loop through all unique country code values in the BigMac index DataFrame
for country_code in bigmac_df.country.unique():
    # Fetch the GDP dataset for the corresponding country 
    df = get_gdp_dataset(api_key, country_code)

    # Skip if the response is empty
    if df is None:
        continue

    # Store in a list DataFrames
    quandl_dfs.append(df)

    # Prevents exceeding the API speed limit
    time.sleep(2)

# Concatenate the list of DataFrames into a single one 
gdp_df = pd.concat(quandl_dfs)
gdp_df.head()

几个地区的 GDP 数据缺失,但这应该可以通过try...except代码块在get_gdp_dataset函数中优雅地处理。运行前面的代码后,你应该看到如下内容:

WWDI/EUR_NY_GDP_PCAP_KD HTTP Error 404: Not Found 
WWDI/ROC_NY_GDP_PCAP_KD HTTP Error 404: Not Found 
WWDI/SIN_NY_GDP_PCAP_KD HTTP Error 404: Not Found 
WWDI/UAE_NY_GDP_PCAP_KD HTTP Error 404: Not Found
日期国家
02016-12-3155478.577294AUS
12015-12-3154800.366396AUS
22014-12-3154293.794205AUS
32013-12-3153732.003969AUS
42012-12-3153315.029915AUS

接下来,我们将使用pandas.merge()合并包含“大麦指数”或人均 GDP 的两个 pandas 数据框。WWDI 的最新人均 GDP 数据记录是在 2016 年底收集的,因此我们将其与 2017 年 1 月的最新大麦指数数据集配对。

对于熟悉 SQL 语言的人来说,pandas.merge()支持四种连接模式,分别是左连接、右连接、内连接和外连接。由于我们只关心在两个 pandas 数据框中都有匹配国家的行,因此我们将选择内连接:

merged_df = pd.merge(bigmac_df[(bigmac_df.Date == "2017-01-31")], gdp_df[(gdp_df.Date == "2016-12-31")], how='inner', on='country')
merged_df.head()

这是合并后的数据框:

 01234
日期 _x31-01-1731-01-1731-01-1731-01-1731-01-17
本地价格5.816.53.09245055
美元汇率1.3566683.223950.828775672.80515.8575
美元价格4.275185.1179453.7283943.6414713.46839
美元购买力平价1.1462453.260870.610672484.18972310.869565
美元估值-15.5102771.145166-26.316324-28.034167-31.454736
美元调整估值-11.923467.5509-18.020811.93196.26715
欧元调整估值-10.263670.7084-16.475914.04138.26976
英镑调整估值7.43771104.382036.536929.6269
日元调整估值9.99688109.2512.3820139.789232.7146
人民币调整估值-5.8243479.1533-12.343919.682813.6258
国家AUSBRAGBRCHLARG
日期 _y31-12-1631-12-1631-12-1631-12-1631-12-16
55478.577310826.271441981.392115019.63310153.99791

使用 Seaborn 简化可视化任务

散点图是科学和商业领域中最常见的图形之一。它特别适合用来展示两个变量之间的关系。虽然我们可以简单地使用 matplotlib.pyplot.scatter 来绘制散点图(有关更多详细信息,请参见第二章,Matplotlib 入门 和 第四章,高级 Matplotlib),我们也可以使用 Seaborn 来构建具有更多高级功能的类似图形。

这两个函数,seaborn.regplot()seaborn.lmplot(),通过散点图、回归线以及回归线周围的 95%置信区间,展示了变量之间的线性关系。它们之间的主要区别在于,lmplot()regplot()FacetGrid 结合在一起,允许我们创建带有颜色编码或分面显示的散点图,从而展示三个或更多变量对之间的交互关系。

seaborn.regplot() 最简单的形式支持 NumPy 数组、pandas Series 或 pandas DataFrame 作为输入。可以通过指定 fit_reg=False 来去除回归线和置信区间。

我们将调查这样一个假设:在人均 GDP 较低的国家,巨无霸价格较便宜,反之亦然。为此,我们将尝试找出巨无霸指数与人均 GDP 之间是否存在相关性:

import seaborn as sns
import matplotlib.pyplot as plt

# seaborn.regplot() returns a matplotlib.Axes object
ax = sns.regplot(x="Value", y="dollar_price", data=merged_df, fit_reg=False)
# We can modify the axes labels just like other ordinary
# Matplotlib objects
ax.set_xlabel("GDP per capita (constant 2000 US$)")
ax.set_ylabel("BigMac index (US$)")
plt.show()

代码将用一个经典的散点图来迎接你:

到目前为止,一切顺利!看起来巨无霸指数与人均 GDP 呈正相关。我们将重新开启回归线,并标记出一些显示极端巨无霸指数值的国家(即 ≥ 5 或 ≤ 2)。同时,默认的绘图样式有些单调;我们可以通过运行 sns.set(style="whitegrid") 来使图表更具活力。还有四种其他样式可供选择,分别是 darkgriddarkwhiteticks

sns.set(style="whitegrid")
ax = sns.regplot(x="Value", y="dollar_price", data=merged_df)
ax.set_xlabel("GDP per capita (constant 2000 US$)")
ax.set_ylabel("BigMac index (US$)")
# Label the country codes which demonstrate extreme BigMac index
for row in merged_df.itertuples():
    if row.dollar_price >= 5 or row.dollar_price <= 2:
     ax.text(row.Value,row.dollar_price+0.1,row.country)
plt.show()

这是带标签的图:

我们可以看到,许多国家的点都落在回归线的置信区间内。根据每个国家的人均 GDP,线性回归模型预测了相应的巨无霸指数。如果实际指数偏离回归模型,则货币价值可能表明其被低估或高估。

通过标记出显示极高或极低值的国家,我们可以清楚地看到,巴西和瑞士的巨无霸价格被高估,而南非、马来西亚、乌克兰和埃及则被低估。

由于 Seaborn 不是一个用于统计分析的包,我们需要使用其他包,例如 scipy.statsstatsmodels,来获得回归模型的参数。在下一个示例中,我们将从回归模型中获取斜率和截距参数,并为回归线上下的点应用不同的颜色:

from scipy.stats import linregress

ax = sns.regplot(x="Value", y="dollar_price", data=merged_df)
ax.set_xlabel("GDP per capita (constant 2000 US$)")
ax.set_ylabel("BigMac index (US$)")

# Calculate linear regression parameters
slope, intercept, r_value, p_value, std_err = linregress(merged_df.Value, merged_df.dollar_price)

colors = []
for row in merged_df.itertuples():
    if row.dollar_price > row.Value * slope + intercept:
        # Color markers as darkred if they are above the regression line
        color = "darkred"
    else:
        # Color markers as darkblue if they are below the regression line
        color = "darkblue"

    # Label the country code for those who demonstrate extreme BigMac index
    if row.dollar_price >= 5 or row.dollar_price <= 2:
        ax.text(row.Value,row.dollar_price+0.1,row.country)

    # Highlight the marker that corresponds to China
    if row.country == "CHN":
        t = ax.text(row.Value,row.dollar_price+0.1,row.country)
        color = "yellow"

    colors.append(color)

# Overlay another scatter plot on top with marker-specific color
ax.scatter(merged_df.Value, merged_df.dollar_price, c=colors)

# Label the r squared value and p value of the linear regression model.
# transform=ax.transAxes indicates that the coordinates are given relative to the axes bounding box, 
# with 0,0 being the lower left of the axes and 1,1 the upper right.
ax.text(0.1, 0.9, "$r²={0:.3f}, p={1:.3e}$".format(r_value ** 2, p_value), transform=ax.transAxes)

plt.show()

这张截图展示了带有颜色标签的图:

与普遍看法相反,看起来中国的货币在 2016 年并没有显著低估,因为其价值位于回归线的 95%置信区间内。

我们还可以将xy值的直方图与散点图结合,使用seaborn.jointplot

通过在jointplot中额外指定kind参数为regresidhexkde中的任意一个,我们可以迅速将图表类型分别更改为回归图、残差图、六边形箱型图或 KDE 轮廓图。

# seaborn.jointplot() returns a seaborn.JointGrid object
g = sns.jointplot(x="Value", y="dollar_price", data=merged_df)

# Provide custom axes labels through accessing the underlying axes object
# We can get matplotlib.axes.Axes of the scatter plot by calling g.ax_joint
g.ax_joint.set_xlabel("GDP per capita (constant 2000 US$)")
g.ax_joint.set_ylabel("BigMac index (US$)")

# Set the title and adjust the margin
g.fig.suptitle("Relationship between GDP per capita and BigMac Index")
g.fig.subplots_adjust(top=0.9)
plt.show()

jointplot如图所示:

这里有一个重要的免责声明。即便我们手中有所有数据,现在依然为时过早,无法对货币估值做出任何结论!劳动力成本、租金、原材料成本和税收等不同的商业因素都可能影响“大麦”定价模型,但这超出了本书的范围。

从网站抓取信息

世界各国的政府或司法管辖区越来越重视开放数据,这旨在增加公民参与和知情决策,并使政策更加开放,接受公众审查。全球一些开放数据倡议的例子包括www.data.gov/(美国)、data.gov.uk/(英国)和data.gov.hk/en/(香港)。

这些数据门户网站通常提供用于程序化访问数据的 API。然而,并非所有数据集都提供 API,因此我们需要依靠老式的网页抓取技术,从网站中提取信息。

Beautiful Soup (www.crummy.com/software/BeautifulSoup/) 是一个非常有用的抓取网站信息的包。基本上,所有带有 HTML 标签的内容都可以使用这个强大的包进行抓取。Scrapy 也是一个不错的网页抓取包,但它更像是一个编写强大网络爬虫的框架。所以,如果你只是需要从页面抓取一个表格,Beautiful Soup 提供了更简单的操作方式。

本章将使用 Beautiful Soup 版本 4.6。要安装 Beautiful Soup 4,我们可以再次通过 PyPI 来安装:

pip install beautifulsoup4

美国失业率和按教育程度划分的收入数据(2017 年)可以通过以下网站获得:www.bls.gov/emp/ep_table_001.htm。目前,Beautiful Soup 不处理 HTML 请求。所以我们需要使用urllib.requestrequests包来获取网页。在这两个选项中,requests包由于其更高层次的 HTTP 客户端接口,使用起来显得更加简便。如果你的系统中没有requests,我们可以通过 PyPI 安装:

pip install requests

在编写网页爬取代码之前,让我们先看一下网页。如果我们使用 Google Chrome 访问劳动统计局网站,就可以检查对应我们需要的表格的 HTML 代码:

接下来,展开<div id="bodytext" class="verdana md">,直到你能看到<table class="regular" cellspacing="0" cellpadding="0" xborder="1">...</table>。当你将鼠标悬停在 HTML 代码上时,页面中的对应部分会被高亮显示:

扩展<table>的 HTML 代码后,我们可以看到列名定义在<thead>...</thead>部分,而表格内容则定义在<tbody>...</tbody>部分。

为了指示 Beautiful Soup 爬取我们需要的信息,我们需要给它明确的指示。我们可以右键单击代码检查窗口中的相关部分,复制格式为 CSS 选择器的唯一标识符:

让我们尝试获取theadtbody的 CSS 选择器,并使用BeautifulSoup.select()方法来爬取相应的 HTML 代码:

import requests
from bs4 import BeautifulSoup

# Specify the url
url = "https://www.bls.gov/emp/ep_table_001.htm"

# Query the website and get the html response
response = requests.get(url)

# Parse the returned html using BeautifulSoup
bs = BeautifulSoup(response.text)

# Select the table header by CSS selector
thead = bs.select("#bodytext > table > thead")[0]

# Select the table body by CSS selector
tbody = bs.select("#bodytext > table > tbody")[0]

# Make sure the code works
print(thead)

你将看到表头的 HTML 代码:

<thead> 
<tr> 
<th scope="col"><p align="center" valign="top"><strong>Educational attainment</strong></p></th> 
<th scope="col"><p align="center" valign="top">Unemployment rate (%)</p></th> 
<th scope="col"><p align="center" valign="top">Median usual weekly earnings ($)</p></th> 
</tr> 
</thead>

接下来,我们将找到所有包含每一列名称的<th></th>标签。我们将构建一个以列头为键的字典列表来保存数据:

# Get the column names
headers = []

# Find all header columns in <thead> as specified by <th> html tags
for col in thead.find_all('th'):
   headers.append(col.text.strip())

# Dictionary of lists for storing parsed data
data = {header:[] for header in headers}

最后,我们解析表格的剩余行,并将数据转换为 pandas DataFrame:

import pandas as pd

# Parse the rows in table body
for row in tbody.find_all('tr'):
    # Find all columns in a row as specified by <th> or <td> html tags
    cols = row.find_all(['th','td'])

    # enumerate() allows us to loop over an iterable, 
    # and return each item preceded by a counter
    for i, col in enumerate(cols):
        # Strip white space around the text
        value = col.text.strip()

        # Try to convert the columns to float, except the first column
        if i > 0:
            value = float(value.replace(',','')) # Remove all commas in string

        # Append the float number to the dict of lists
        data[headers[i]].append(value)

# Create a data frame from the parsed dictionary
df = pd.DataFrame(data)

# Show an excerpt of parsed data
df.head()

我们现在应该能够重现主表格的前几行:

学历中位数通常每周收入($)失业率(%)
0博士学位1743.01.5
1专业学位1836.01.5
2硕士学位1401.02.2
3本科及以上学位1173.02.5
4大专及以上学位836.03.4

主 HTML 表格已经被格式化为结构化的 pandas DataFrame。我们现在可以继续可视化数据了。

Matplotlib 图形后端

绘图的代码被认为是 Matplotlib 中的前端部分。我们第一次提到后端是在第一章,Matplotlib 简介,当时我们在谈论输出格式。实际上,Matplotlib 后端有着比仅仅支持图形格式更多的差异。后端在幕后处理了很多事情!这决定了绘图功能的支持。例如,LaTeX 文本布局仅由 Agg、PDF、PGF 和 PS 后端支持。

非交互式后端

到目前为止,我们已经使用了几种非交互式后端,包括 Agg、Cairo、GDK、PDF、PGF、PS 和 SVG。大多数后端无需额外依赖即可工作,但 Cairo 和 GDK 分别需要 Cairo 图形库或 GIMP 绘图工具包才能运行。

非交互式后端可以进一步分为两组——矢量或光栅。矢量图形通过点、路径和形状来描述图像,这些都是通过数学公式计算得出的。无论缩放多少,矢量图形总是显得平滑,并且其大小通常比光栅图形要小。PDF、PGF、PS 和 SVG 后端属于矢量组。

光栅图形通过有限数量的微小颜色块(像素)来描述图像。所以,如果我们足够放大,就会看到图像的不平滑表现,换句话说,就是像素化。通过提高图像的分辨率或每英寸点数DPI),我们不太可能观察到像素化现象。Agg、Cairo 和 GDK 属于这一类后端。下表总结了非交互式后端的主要功能和差异:

后端矢量还是光栅?输出格式
Agg光栅.png
Cairo矢量/光栅.pdf, .png, .ps, .svg
PDF矢量.pdf
PGF矢量.pdf, .pgf
PS矢量.ps
SVG矢量.svg
GDK*光栅.png, .jpg, .tiff

*Matplotlib 2.0 中已弃用。

通常,我们不需要手动选择后端,因为默认的选择适用于大多数任务。另一方面,我们可以通过在首次导入 matplotlib.pyplot 之前使用 matplotlib.use() 方法指定后端:

import matplotlib
matplotlib.use('SVG') # Change to SVG backend
import matplotlib.pyplot as plt
import textwrap # Standard library for text wrapping

# Create a figure
fig, ax = plt.subplots(figsize=(6,7))

# Create a list of x ticks positions
ind = range(df.shape[0])

# Plot a bar chart of median usual weekly earnings by educational attainments
rects = ax.barh(ind, df["Median usual weekly earnings ($)"], height=0.5)

# Set the x-axis label
ax.set_xlabel('Median weekly earnings (USD)')

# Label the x ticks
# The tick labels are a bit too long, let's wrap them in 15-char lines
ylabels=[textwrap.fill(label,15) for label in df["Educational attainment"]]
ax.set_yticks(ind)
ax.set_yticklabels(ylabels)

# Give extra margin at the bottom to display the tick labels
fig.subplots_adjust(left=0.3)

# Save the figure in SVG format
plt.savefig("test.svg")

交互式后端

Matplotlib 可以构建比静态图形更具互动性的图形,这对于读者来说更具吸引力。有时,图形可能会被过多的图形元素淹没,使得难以分辨单独的数据点。在其他情况下,一些数据点可能看起来非常相似,肉眼很难察觉它们之间的差异。交互式图形可以通过允许我们缩放、平移和按照自己的方式探索图形来解决这两种情况。

通过使用交互式后端,Matplotlib 中的图形可以嵌入到图形用户界面GUI)应用程序中。默认情况下,Matplotlib 支持将 Agg 光栅图形渲染器与多种 GUI 工具包配对,包括 wxWidgets(Wx)、GIMP 工具包(GTK+)、Qt 和 TkInter(Tk)。由于 Tkinter 是 Python 的事实标准 GUI,构建于 Tcl/Tk 之上,我们只需在独立的 Python 脚本中调用 plt.show() 就可以创建交互式图形。我们可以尝试将以下代码复制到单独的文本文件中,并命名为 interactive.py。然后,在终端(Mac/Linux)或命令提示符(Windows)中输入 python interactive.py。如果你不确定如何打开终端或命令提示符,请参考第一章,Matplotlib 介绍,以获取更多细节:

import matplotlib
import matplotlib.pyplot as plt
import textwrap
import requests
import pandas as pd
from bs4 import BeautifulSoup
# Import Matplotlib radio button widget
from matplotlib.widgets import RadioButtons

url = "https://www.bls.gov/emp/ep_table_001.htm"
response = requests.get(url)
bs = BeautifulSoup(response.text)
thead = bs.select("#bodytext > table > thead")[0]
tbody = bs.select("#bodytext > table > tbody")[0]

headers = []
for col in thead.find_all('th'):
    headers.append(col.text.strip())

data = {header:[] for header in headers}
for row in tbody.find_all('tr'):
    cols = row.find_all(['th','td'])

    for i, col in enumerate(cols):
        value = col.text.strip()
        if i > 0:
            value = float(value.replace(',','')) 
        data[headers[i]].append(value)

df = pd.DataFrame(data)

fig, ax = plt.subplots(figsize=(6,7))
ind = range(df.shape[0])
rects = ax.barh(ind, df["Median usual weekly earnings ($)"], height=0.5)
ax.set_xlabel('Median weekly earnings (USD)')
ylabels=[textwrap.fill(label,15) for label in df["Educational attainment"]]
ax.set_yticks(ind)
ax.set_yticklabels(ylabels)
fig.subplots_adjust(left=0.3)

# Create axes for holding the radio selectors.
# supply [left, bottom, width, height] in normalized (0, 1) units
bax = plt.axes([0.3, 0.9, 0.4, 0.1])
radio = RadioButtons(bax, ('Weekly earnings', 'Unemployment rate'))

# Define the function for updating the displayed values
# when the radio button is clicked
def radiofunc(label):
  # Select columns from dataframe depending on label
  if label == 'Weekly earnings':
    data = df["Median usual weekly earnings ($)"]
    ax.set_xlabel('Median weekly earnings (USD)')
  elif label == 'Unemployment rate':
    data = df["Unemployment rate (%)"]
    ax.set_xlabel('Unemployment rate (%)')

  # Update the bar heights
  for i, rect in enumerate(rects):
    rect.set_width(data[i])

  # Rescale the x-axis range
  ax.set_xlim(xmin=0, xmax=data.max()*1.1)

  # Redraw the figure
  plt.draw()
radio.on_clicked(radiofunc)

plt.show()

我们将看到一个类似于以下的弹出窗口。我们可以平移、缩放以选择区域、配置子图边距、保存,并通过点击底部工具栏上的按钮在不同视图之间来回切换。如果我们将鼠标悬停在图表上,还可以在右下角观察到精确的坐标。这个功能对于剖析彼此接近的数据点非常有用:

接下来,我们将通过在图形上方添加一个单选按钮控件来扩展应用程序,从而可以在显示每周收入或失业率之间切换。单选按钮位于matplotlib.widgets中,我们将把一个数据更新函数附加到按钮的.on_clicked()事件上。你可以将以下代码粘贴到之前代码示例(interactive.py)中的plt.show()行之前。让我们看看它是如何工作的:

# Import Matplotlib radio button widget
from matplotlib.widgets import RadioButtons

# Create axes for holding the radio selectors.
# supply [left, bottom, width, height] in normalized (0, 1) units
bax = plt.axes([0.3, 0.9, 0.4, 0.1])
radio = RadioButtons(bax, ('Weekly earnings', 'Unemployment rate'))

# Define the function for updating the displayed values
# when the radio button is clicked
def radiofunc(label):
    # Select columns from dataframe, and change axis label depending on selection
    if label == 'Weekly earnings':
        data = df["Median usual weekly earnings ($)"]
        ax.set_xlabel('Median weekly earnings (USD)')
    elif label == 'Unemployment rate':
        data = df["Unemployment rate (%)"]
        ax.set_xlabel('Unemployment rate (%)')

    # Update the bar heights
    for i, rect in enumerate(rects):
        rect.set_width(data[i])

    # Rescale the x-axis range
    ax.set_xlim(xmin=0, xmax=data.max()*1.1)

    # Redraw the figure
    plt.draw()

# Attach radiofunc to the on_clicked event of the radio button
radio.on_clicked(radiofunc)

你将看到图表顶部出现一个新的单选框。尝试在两种状态之间切换,看看图形是否会相应更新。完整代码也可以在代码包中找到:

在我们结束本节之前,我们将介绍一种很少在书籍中提及的交互式后端。从 Matplotlib 1.4 开始,提供了一种专为 Jupyter Notebook 设计的交互式后端。要调用它,我们只需要在笔记本的开始处粘贴%matplotlib notebook。我们将调整本章早些时候的一个示例来使用这个后端:

# Import the interactive backend for Jupyter Notebook
%matplotlib notebook
import matplotlib
import matplotlib.pyplot as plt
import textwrap

fig, ax = plt.subplots(figsize=(6,7))
ind = range(df.shape[0])
rects = ax.barh(ind, df["Median usual weekly earnings ($)"], height=0.5)
ax.set_xlabel('Median weekly earnings (USD)')
ylabels=[textwrap.fill(label,15) for label in df["Educational attainment"]]
ax.set_yticks(ind)
ax.set_yticklabels(ylabels)
fig.subplots_adjust(left=0.3)

# Show the figure using interactive notebook backend
plt.show()

以下交互式图表将嵌入到你的 Jupyter Notebook 中:

创建动画图

Matplotlib 最初并不是为动画包设计的,因此在某些高级用途中它的表现可能显得有些迟缓。对于以动画为中心的应用程序,PyGame 是一个非常好的替代方案(www.pygame.org),它支持 OpenGL 和 Direct3D 加速图形,提供极致的动画速度。不过,Matplotlib 在大多数时候的表现是可以接受的,我们将引导你完成创建比静态图更具吸引力的动画的步骤。

在开始制作动画之前,我们需要在系统上安装 FFmpeg、avconv、mencoder 或 ImageMagick 其中之一。这些附加依赖项没有与 Matplotlib 捆绑在一起,因此我们需要单独安装它们。我们将带你逐步完成安装 FFmpeg 的步骤。

对于基于 Debian 的 Linux 用户,只需在终端中输入以下命令即可安装 FFmpeg。

sudo apt-get install ffmpeg

对于 Mac 用户,Homebrew(brew.sh/)是搜索和安装ffmpeg软件包的最简单方式。如果你没有安装 Homebrew,可以将以下代码粘贴到终端中进行安装。

/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

然后,我们可以通过在终端输入以下命令来安装 FFmpeg:

brew install ffmpeg

另外,您也可以通过将二进制文件复制到系统路径(例如,/usr/local/bin)来安装 FFmpeg(evermeet.cx/ffmpeg/)。

对于 Windows 用户,安装过程稍微复杂一些,但幸运的是,wikiHow 上有一份详细的安装指南(www.wikihow.com/Install-FFmpeg-on-Windows)。

Matplotlib 提供了两种主要的动画创建接口:TimedAnimationFuncAnimationTimedAnimation 适用于创建基于时间的动画,而 FuncAnimation 可以根据自定义函数来创建动画。由于 FuncAnimation 提供了更高的灵活性,我们将在本节中仅探讨 FuncAnimation 的使用。有兴趣的读者可以参考官方文档(matplotlib.org/api/animation_api.html)了解更多关于 TimedAnimation 的信息。

在以下示例中,我们通过假设每年增加 5% 来模拟中位数周薪的变化。我们将创建一个自定义函数—animate,该函数返回在每一帧中发生变化的 Matplotlib Artist 对象。该函数将与一些额外的参数一起传递给 animation.FuncAnimation()

import textwrap 
import matplotlib.pyplot as plt
import random
# Matplotlib animation module
from matplotlib import animation
# Used for generating HTML video embed code
from IPython.display import HTML

# Adapted from previous example, codes that are modified are commented
fig, ax = plt.subplots(figsize=(6,7))
ind = range(df.shape[0])
rects = ax.barh(ind, df["Median usual weekly earnings ($)"], height=0.5)
ax.set_xlabel('Median weekly earnings (USD)')
ylabels=[textwrap.fill(label,15) for label in df["Educational attainment"]]
ax.set_yticks(ind)
ax.set_yticklabels(ylabels)
fig.subplots_adjust(left=0.3)

# Change the x-axis range
ax.set_xlim(0,7600)

# Add a text annotation to show the current year
title = ax.text(0.5,1.05, "Median weekly earnings (USD) in 2017", 
 bbox={'facecolor':'w', 'alpha':0.5, 'pad':5},
 transform=ax.transAxes, ha="center")

# Animation related stuff
n=30 #Number of frames

def animate(frame):
    # Simulate 5% annual pay rise 
    data = df["Median usual weekly earnings ($)"] * (1.05 ** frame)

    # Update the bar heights
    for i, rect in enumerate(rects):
        rect.set_width(data[i])

    # Update the title
    title.set_text("Median weekly earnings (USD) in {}".format(2016+frame))

    return rects, title

# Call the animator. Re-draw only the changed parts when blit=True. 
# Redraw all elements when blit=False
anim=animation.FuncAnimation(fig, animate, blit=False, frames=n)

# Save the animation in MPEG-4 format
anim.save('test.mp4')

# OR--Embed the video in Jupyter Notebook
HTML(anim.to_html5_video())

以下是生成的视频:

github.com/PacktPublishing/Matplotlib-for-Python-Developers-Second-Edition/blob/master/extra_ch9/ch09_animation.mp4

在前面的示例中,我们以 MPEG-4 编码视频的形式输出动画。该视频也可以以 H.264 编码视频的形式嵌入到 Jupyter Notebook 中。只需要调用 Animation.to_html5_video() 方法,并将返回的对象传递给 IPython.display.HTML,视频编码和 HTML5 代码生成会在后台自动完成。

从版本 2.2.0 开始,Matplotlib 支持通过 Pillow 图像库和 ImageMagick 创建动画 GIF。由于互联网对 GIF 的热爱永无止境,让我们来学习如何创建一个 GIF 吧!

在我们能够创建动画 GIF 之前,我们需要先安装 ImageMagick。所有主要平台的下载链接和安装说明可以在此找到:www.imagemagick.org/script/download.php

安装该包后,我们可以通过将 anim.save('test.mp4') 改为 anim.save('test.gif', writer='imagemagick', fps=10) 来生成动画 GIF。fps 参数表示动画的帧率。

以下是生成的动画 GIF:

github.com/PacktPublishing/Matplotlib-for-Python-Developers-Second-Edition/blob/master/extra_ch9/ch%2009_GIF.gif

概述

在本章中,你学习了如何使用多功能的 pandas 包解析在线的 CSV 或 JSON 格式数据。你还进一步学习了如何筛选、子集化、合并和处理数据以提取洞察。最后,你学会了如何直接从网站上抓取信息。现在,你已经掌握了可视化时间序列、单变量和双变量数据的知识。本章以一系列有用的技巧结束,这些技巧可以帮助你定制图形美学,以进行有效的故事讲述。

呼!我们刚刚完成了一个长章节,去吃个汉堡,休息一下,放松放松吧。