IT / 地理/GIS / 气象/Meteorology · 2022年2月13日 0

Cartopy是如何对Matplotlib做移花接木的

你有没有发现,当你用python画图时使用cartopy的投影参数以后,你的ax对象会莫名其妙多出好多新的方法,比如用于绘制岸线的 ax.coastlines() ,或者缩放到全球视角的ax.set_global()。这些都是matplotlib原生Axes对象所没有的方法,而你创建子图的时候,明明用的是matplotlib原生的工厂函数创建的画轴啊,cartopy是怎么做到偷梁换柱,移花接木的呢?下面我们就来解密一下。

在之前写cnmaps包的时候,我就大致读了一下cartopy对GeoAxes对象的实现源码,其实GeoAxes就是Axes的一个增强子类,但是我很好奇,它是怎么实现通过传递一个参数而直接替换掉matplotlib原生的Axes对象的,我们看下面的例子:

$ ipython
Python 3.6.13 |Anaconda, Inc.| (default, Feb 23 2021, 12:58:59) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.16.1 -- An enhanced Interactive Python. Type '?' for help.

In [1]: import matplotlib.pyplot as plt

In [2]: import cartopy.crs as ccrs

In [3]: fig1 = plt.figure()

In [4]: type(fig1.add_subplot())
Out[4]: matplotlib.axes._subplots.AxesSubplot

In [5]: fig2 = plt.figure()

In [6]: type(fig2.add_subplot(projection=ccrs.PlateCarree()))
Out[6]: cartopy.mpl.geoaxes.GeoAxesSubplot

从上面的例子可以看出,若我们调用add_subplot时不传入projection参数,那么返回结果的类型就是matplotlib原生的AxesSubplot对象,而如果我们传入了projection对象,那么返回结果的类型就变成了cartopy自己定义的GeoAxesSubplot类型。用一个参数就可以控制返回值类型,这么神奇的吗?

后来查阅了matplotlib的官方文档,在一个角落里发现了这么一段描述:

For more complex, parameterisable projections, a generic "projection" object may be defined which includes the method _as_mpl_axes_as_mpl_axes should take no arguments and return the projection's axes subclass and a dictionary of additional arguments to pass to the subclass' __init__ method. Subsequently a parameterised projection can be initialised with:

fig.add_subplot(projection=MyProjection(param1=param1_value))

where MyProjection is an object which implements a _as_mpl_axes method.

上面的大概意思是,对于复杂投影,你可以自定义一个投影对象,然后在类里实现一个无参数的_as_mpl_axes方法,这个方法的返回值应包括投影画轴的子类和一个准备继续传递给子类的参数字典,之后就可以用类似于fig.add_subplot(projection=MyProjection(param1=param1_value))的方式调用了。而且MyProjection应该是一个内置了_as_mpl_axes方法的对象。

原来是matplotlib给大家留了后门,以扩展它的投影支持,其实matplotlib原生支持一些投影,例如这里,但是它显然还是想让专业的人做专业的事,所以它在页面里推荐了cartopy。

我们现在知道了matplotlib留了后门,那具体cartopy是怎么实现的呢?我们来看一下cartopy在写投影的时候都写了写什么,以0.19.0版本为例:

class Projection(CRS, metaclass=ABCMeta):
    ...
    def _as_mpl_axes(self):
        import cartopy.mpl.geoaxes as geoaxes
        return geoaxes.GeoAxes, {'map_projection': self}
    ...

由于篇幅问题,我仅截取了部分内容,完整代码请点击这里。cartopy在实现Projection类的时候,内置了_as_mpl_axes方法,且返回了它自定义的GeoAxes类,这个GeoAxes就是cartopy的主角,它是matplotlib.axes.Axes的子类,它继承了Axes的属性和方法(当然也重写了一些方法),同时也新添加了一些特有的方法,例如cartopy特有的 coastlines()set_global() 等方法就是在GeoAxes中定义的,想要查看GeoAxes的具体实现代码可以点击这里

Projection类内置了_as_mpl_axes方法以后,它就具有了一种移花接木的能力,Axes对象在实例化的时候会根据_as_mpl_axes函数的返回值来决定自己的返回值。

说了这么多,不实操一下吗?当然要,下面我就用一个简单的例子,来创建一个自定义画轴。

$ ipython
Python 3.6.13 |Anaconda, Inc.| (default, Feb 23 2021, 12:58:59) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.16.1 -- An enhanced Interactive Python. Type '?' for help.

In [1]: import matplotlib.pyplot as plt

In [2]: import matplotlib

In [3]: class ClarmyAxes(matplotlib.axes.Axes):
   ...:     def __init__(self, *args, **kwargs):
   ...:         super().__init__(*args, **kwargs)
   ...: 

In [4]: class ClarmyProjection(object):
   ...:     def _as_mpl_axes(self):
   ...:         return ClarmyAxes, {}
   ...: 

上面就是仿照cartopy的方式,我们自定义了一个ClarmyAxesClarmyProjectionClarmyProjection类内置_as_mpl_axes方法然后返回ClarmyAxes对象和空的参数,下面我们来实验一下它有没有生效。

In [5]: fig = plt.figure()

In [6]: type(fig.add_subplot(111, projection=ClarmyProjection()))
Out[6]: matplotlib.axes._subplots.ClarmyAxesSubplot

我们发现它返回的是一个matplotlib.axes._subplots.ClarmyAxesSubplot对象,看起来是生效了,因为matplotlib原先不可能存在一个ClarmyAxesSubplot子轴对象,但是它还是隶属于matplotlib.axes模块里,其实这是matplotlib根据我们的ClarmyAxes临时创建起来的子轴,如果想要再定制化一些,可以像cartopy一样加入下面这段代码:

In [7]: ClarmyAxesSubplot = matplotlib.axes.subplot_class_factory(ClarmyAxes)

In [8]: ClarmyAxesSubplot.__module__ = ClarmyAxes.__module__

这样再做实例化的时候,它就与matplotlib划清界限了。

In [9]: fig = plt.figure()

In [10]: type(fig.add_subplot(111, projection=ClarmyProjection()))
Out[10]: __main__.ClarmyAxesSubplot

是不是还挺好玩的。