卷2:第11章 matplotlib

matplotlib是基于Python的绘图库,广泛用于Python科学计算界。它完整支持二维绘图以及部分支持三维绘图。该绘图库致力于能适应广泛的用户需求。它可以根据所选的用户接口工具来嵌入绘图算法。与此同时,对于使用GTK+、Qt、Tk、FLTK、wxWidgets与Cocoa的所有主要桌面操作系统,matplotlib能支持交互式绘图。在Python的交互式shell中,我们可以使用简单的、过程式的命令交互式地调用matplotlib来生成图形,与使用Mathematica、IDL或者MATLAB绘图非常相似。matplotlib也可以嵌入到无报文头的Web服务器中,以提供基于光栅(如PNG格式)与向量(如Postscript、PDF以及纸面效果很好的SVG格式)这两种格式的图形硬拷贝。

11.1 硬件锁问题 我们其中一位开发者(John Hunter)与他的研究癫痫症的同事们试图在不借助专有软件的情况下进行脑皮层电图(ECoG)分析,于是便有了最初的matplotlib。John Hunter当时所在的实验室只有一份电图分析软件的许可证,但有各式各样的工作人员,如研究生、医科学生、博士后、实习生、以及研究员,他们轮流共享该专有软件的硬件电子锁。生物医学界广泛使用MATLAB进行数据分析与可视化,所以Hunter着手使用基于MATLAB的matplotlib来代替专有软件,这样很多研究员都可以使用并且对其进行扩展。但是MATLAB天生将数据当作浮点数的数组来处理。然而在实际情况中,癫痫手术患者的医疗记录具有多种数据形式(CT、MRI、ECoG与EEG等),并且存储在不同的服务器上。MATLAB作为数据管理系统勉强能应付这样的复杂性。由于感到MATLAB不适合于这项任务,Hunter开始编写一个新的建立在用户接口工具GTK+(当时是Linux下的主流桌面视窗系统)之上的Python应用程序。

所以matplotlib这一GTK+应用程序最初便被开发成EEG/ECoG可视化工具。这样的用例决定了它最初的软件架构。matplotlib最初的设计也服务于另一个目的:代替命令驱动的交互式图形生成(这一点MATLAB做得很好)工具。MATLAB的设计方法使得加载数据文件与绘图这样的任务非常简单,而要使用完全面向对象的API则会在语法上过于繁琐。所以matplotlib也提供状态化的脚本编程接口来快速、简单地生成与MATLAB类似的图形。因为matplotlib是Python库,所以用户可以使用Python中各种丰富的数据结构,如列表、辞典与集合等等。

图11.1:最初的matplotlib程序——ECoG查看器

11.2 matplotlib软件架构概述

顶层的matplotlib对象名为Figure,它包含与管理某个图形的所有元素。matplotlib必须完成的一个核心架构性任务是实现Figure的绘制与操作框架,并且做到该框架与Figure到用户视窗接口或硬拷贝渲染行为是分离的。这使得我们可以为Figure添加越来越复杂的特性与逻辑,同时保持“后端”或输出设备的相对简化。matplotlib不仅封装了用于向多种设备渲染的绘图接口,还封装了基本事件处理以及多数流行的用户界面工具的视窗功能。因此,用户可以创建相当丰富的交互式图形算法与用户界面工具(用到可能存在的鼠标与键盘),而又不必修改matplotlib已经支持的6种界面工具。

要实现这些,matplotlib的架构被逻辑性地分为三层。这三层逻辑可以视为一个栈。每层逻辑知道如何与其下的一层逻辑进行通信,但在下层逻辑看来,上层是透明的。这三层从底向上分别为:后端、美工与脚本。

11.2.1 后端

matplotlib逻辑栈最底层是后端,它具体实现了下面的抽象接口类:

  • FigureCanvas对绘图表面(如“绘图纸”)的概念进行封装。
  • Renderer执行绘图动作(如“画笔”)。
  • Event处理键盘与鼠标事件这样的用户输入。

对于如Qt这样的用户界面工具,FigureCanvas中包含的具体实现可以完成三个任务:将自身嵌入到原生的Qt视窗(QtGui.QMainWindow)中,能将matplotlib的Renderer命令转换到canvas上(QtGui.QPainter),以及将原生Qt事件转换到matplotlib的Event框架下(后者产生回调信号让上行监听者进行处理)。抽象基类定义在matplotlib.backend_bases中,且所有派生类都定义在如matplotlib.backends.backend_qt4agg这样的专用模块中。对于专门生成硬拷贝输出(如PDF、PNG、SVG或PS)的纯图像后端而言,FigureCanvas的实现可能只是简单地建立一个类似文件的对象,其中定义默认的文件头、字体与宏函数,以及Renderer创建的个别对象(如直线、文本与矩形等)。

Renderer的任务是提供底层的绘图接口,即在画布上绘图的动作。上文已经提到,最初的matplotlib程序是一个基于GTK+的ECoG查看器,而且很多早期设计灵感都源自当时已有的GDK/GTK+的API。最初Renderer的API源自GDK的Drawable接口,后者实现了draw_pointdraw_linedraw_rectangledraw_imagedraw_polygon以及draw_glyphs这样的基本方法。我们完成的每个不同后端——最早有PostScript与GD——都实现了GDK的Drawable,并将其转换为独立于后端的原生绘图命令。如上所述,这毫无必要地增加了后端的实现复杂度,原因是单独实现Drawable造成函数泛滥。此后,Renderer已经被极大的简化,将matplotlib移植到新的用户界面或文件格式已经是非常简单的过程。

一个对matplotlib有利的设计决定是支持使用C++模板库Anti-Grain Geometry(缩写为agg[She06])的基于像素点的核心渲染器。这是一个高性能库,可以进行2D反锯齿渲染,生成的图像非常漂亮。matplotlib支持将agg后端渲染的像素缓存插入到每种支持的用户界面中,所以在不同的UI与操作系统下都能得到精确像素点的图形。因为matplotlib生成的PNG输出也使用agg渲染器,所以硬拷贝与屏幕显示完全相同,也就是说在不同的UI与操作系统下,PNG的输出所见即所得。

matplotlib的Event框架将key-press-eventmouse-motion-event这样的潜在UI事件映射到KeyEventMouseEvent类。用户可以连接到这些事件进行函数回调,以及图形与数据的交互,如要pick一个或一组数据点,或对图形或其元素的某方面性质进行操作。下面的示例代码演示了当用户键入‘t’时,对Axes窗口中的线段进行显示开关。

import numpy as np
import matplotlib.pyplot as plt

def on_press(event):
    if event.inaxes is None: return
    for line in event.inaxes.lines:
        if event.key=='t':
            visible = line.get_visible()
            line.set_visible(not visible)
    event.inaxes.figure.canvas.draw()

fig, ax = plt.subplots(1)

fig.canvas.mpl_connect('key_press_event', on_press)

ax.plot(np.random.rand(2, 20))

plt.show()

对底层UI事件框架的抽象使得matplotlib的开发者与最终用户都可以编写UI事件处理代码,而且“一次编写,随处运行”。譬如,在所有用户界面下都可以对matplotlib图像进行交互式平移与放缩,这种交互式操作就是在matplotlib的事件框架下实现的。

11.2.2 Artis

Artist层次结构处于matplotlib的中间层,负责很大一部分繁重的计算任务。延续之前将后端的FigureCanvas看作画纸的比喻,Artis对象知道如何用Renderer(画笔)在画布上画出墨迹。matplotlib中的Figure就是一个Artist对象实例。标题、直线、刻度标记以及图像等等都对应某个Artist实例(如图11.3)。Artist的基类是matplotlib.artist.Artist,其中包含所有Artist的共享属性,包括从美工坐标系统到画布坐标系统的变换(后面将详细介绍)、可见性、定义用户可绘制区域的剪切板、标签,以及处理“选中”这样的用户交互动作的接口,即在美工层检测鼠标点击事件。

图11.2:matplotlib生成的图形

图11.3:用于绘制图11.2的Artist实例的层次结构

Artist层于后端之间的耦合性存在于draw方法中。譬如,下面假想的SomeArtist类是Artist的子类,它要实现的关键方法是draw,用来传递给后端的渲染器。Artist不知道渲染器要向哪种后端进行绘制(PDF、SVG与GTK+绘图区等),但知道Renderer的API,并且会调用适当的方法(draw_textdraw_path)。因为Renderer能访问画布,并且知道如何绘制,所以draw方法将Artist的抽象表示转换为像素缓存中的颜色、SVG文件中的轨迹或者其他具体表示。

class SomeArtist(Artist):
    'An example Artist that implements the draw method'

    def draw(self, renderer):
        """Call the appropriate renderer methods to paint self onto canvas"""
        if not self.get_visible():  return

        # create some objects and use renderer to draw self here
        renderer.draw_path(graphics_context, path, transform)

该层次结构中有两种类型的Artist。基本Artist表示我们在图形中能看到的一类对象,如Line2DRectangleCircleText。复合Artist是Artist的集合,如AxisTickAxesFigure。每个复合Artsit可能包含其他复合Artist与基本Artist。譬如,Figure包含一个或多个Axes,并且Figure的背景是基本的Rectangle

最重要的复合Artist是Axes,其中定义了大多数matplot的绘图方法。Axes不仅仅包含大多数构成绘图背景(如标记、轴线、网格线、色块等)的图形元素,还包括了大量生成基本Artist并添加到Axes实例中的帮助函数。譬如,表11.1列出了一些Axes函数,这些函数进行对象的绘制,并将它们存储在Axes实例中。

表11.1:Axes的方法样例及其创建的Artist实例

方法 创建对象 存储位置
**Axes.imshow** 一到多个**matplotlib.image.AxesImage** **Axes.images**
**Axes.hist** 大量**matplotlib.patch.Rectangle** **Axes.patches**
**Axes.plot** 一到多个**matplotlib.lines.Line2D** **Axes.lines**

下面这个简单的Python脚本解释了以上架构。它定义了后端,将Figure链接至该后端,然后使用数组库numpy创建10,000个正太分布的随机数,最后绘制出它们的柱状图。

# Import the FigureCanvas from the backend of your choice
#  and attach the Figure artist to it.
from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
from matplotlib.figure import Figure
fig = Figure()
canvas = FigureCanvas(fig)

# Import the numpy library to generate the random numbers.
import numpy as np
x = np.random.randn(10000)

# Now use a figure method to create an Axes artist; the Axes artist is
#  added automatically to the figure container fig.axes.
# Here "111" is from the MATLAB convention: create a grid with 1 row and 1
#  column, and use the first cell in that grid for the location of the new
#  Axes.
ax = fig.add_subplot(111)

# Call the Axes method hist to generate the histogram; hist creates a
#  sequence of Rectangle artists for each histogram bar and adds them
#  to the Axes container.  Here "100" means create 100 bins.
ax.hist(x, 100)

# Decorate the figure with a title and save it.
ax.set_title('Normal distribution with $\mu=0, \sigma=1$')
fig.savefig('matplotlib_histogram.png')

11.2.3 脚本层(pyplot

使用以上API的脚本效果很好,尤其是对于程序员而言,并且在编写Web应用服务器、UI应用程序或者是与其他开发人员共享的脚本时,这通常是比较合适的编程范式。对于日常用途,尤其对于非专业程序员而要完成一些交互式的研究工作的实验科学家而言,以上API的语法可能有些难以掌握。大多数用于数据分析与可视化的专用语言都会提供轻量级的脚本接口来简化一些常见任务。matplotlib在其matplotlib.pyplot接口中便实现了这一点。以上代码改用pyplot之后如下所示。

import matplotlib.pyplot as plt
import numpy as np

x = np.random.randn(10000)
plt.hist(x, 100)
plt.title(r'Normal distribution with $\mu=0, \sigma=1$')
plt.savefig('matplotlib_histogram.png')
plt.show()

图11.4:用pyplot绘制的柱状图

pyplot是一个状态化接口,大部分工作是处理样本文件的图形与坐标的生成,以及与所选后端的连接。它还维护了模块级的内部数据结构。这些数据结构表示了直接接收绘图命令的当前图形与坐标

下面仔细分析示例脚本中比较重要的几行,观察其内部状态的管理方式。

  • import matplotlib.pyplot as plt:当pyplot模块被加载时,它分析本地配置文件。配置文件除了完成一些其他工作外,主要声明了默认的后端。可能是类似QtAgg的用户接口后端,于是上面的脚本将导入GUI框架并启动嵌入了图形的Qt窗口;或者可以是一个类似Agg的纯图像后端,这样脚本会生成硬拷贝输出然后退出。
  • plt.hist(x, 100):这是脚本中第一个绘图命令。pyplot会检测其内部数据结构已查看是否存在当前Figure实例。如果存在,则提取当前Axes,并将绘图行为导向Axes.hist的API调用。在该脚本中不存在Figure实例,所以会生成一个FIgureAxes,并将它们设为当前值,然后将绘图行为导向Axes.hist。 plt.hist(x, 100): This is the first plotting command in the script. pyplot will check its internal data structures to see if there is a current Figure instance. If so, it will extract the current Axes and direct plotting to the Axes.hist API call. In this case there is none, so it will create a Figure and Axes, set these as current, and direct the plotting to Axes.hist. plt.title(r'Normal distribution with $\mu=0, \sigma=1$'): As above, pyplot will look to see if there is a current Figure and Axes. Finding that there is, it will not create new instances but will direct the call to the existing Axes instance method Axes.set_title. plt.show(): This will force the Figure to render, and if the user has indicated a default GUI backend in their configuration file, will start the GUI mainloop and raise any figures created to the screen.

A somewhat stripped-down and simplified version of pyplot's frequently used line plotting function matplotlib.pyplot.plot is shown below to illustrate how a pyplot function wraps functionality in matplotlib's object-oriented core. All other pyplot scripting interface functions follow the same design.

@autogen_docstring(Axes.plot)
def plot(*args, **kwargs):
    ax = gca()

    ret = ax.plot(*args, **kwargs)
    draw_if_interactive()

    return ret

The Python decorator @autogen_docstring(Axes.plot) extracts the documentation string from the corresponding API method and attaches a properly formatted version to the pyplot.plot method; we have a dedicated module matplotlib.docstring to handle this docstring magic. The *args and **kwargs in the documentation signature are special conventions in Python to mean all the arguments and keyword arguments that are passed to the method. This allows us to forward them on to the corresponding API method. The call ax = gca() invokes the stateful machinery to "get current Axes" (each Python interpreter can have only one "current axes"), and will create the Figure and Axes if necessary. The call to ret = ax.plot(*args, **kwargs) forwards the function call and its arguments to the appropriate Axes method, and stores the return value to be returned later. Thus the pyplot interface is a fairly thin wrapper around the core Artist API which tries to avoid as much code duplication as possible by exposing the API function, call signature and docstring in the scripting interface with a minimal amount of boilerplate code.