首页 > 解决方案 > 如何在 matplotlib-cartopy geoaxes 中正确水平打包不同的补丁,而不会造成补丁之间的间隙?

问题描述

最近,我一直在检查 matplotlib.offset 类(即:AuxTransformBox、VPacker、HPacker、TextArea)的适用性。

在我的研究中,我证实有时 Packer 类(VPacker 和 HPacker)确实会在提供的补丁之间插入一些间隙。因此,它们的连接变得笨拙,如果不是全部错误的话。

在下面的脚本(改编自此处)中,我尝试应用 matplotlib.offset 类为每个地理轴(cartopy 的地理轴)创建比例尺。请注意,AnchoredScaleBar(AnchoredOffsetbox 的子类)实现了整个 VPacker 和 HPacker 操作。在每个返回的地图中,都有一个比例尺(或至少是其中的一部分)。

这是代码:

import cartopy.crs as ccrs
import cartopy.geodesic as cgeo
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Rectangle
from matplotlib.offsetbox import (AuxTransformBox, VPacker, HPacker, TextArea)
import matplotlib.transforms as transforms


from matplotlib.offsetbox import AnchoredOffsetbox


class AnchoredScaleBar(AnchoredOffsetbox):
    def __init__(self, ax, transform, xcoords, height, xlabels=None,
                 ylabels=None, fontsize=4,
                 pad=0.1, borderpad=0.1, sep=2, prop=None, **kwargs):
        """
        Draw a horizontal and/or vertical  bar with the size in
        data coordinate of the give axes. A label will be drawn
        underneath (center-aligned).

        - transform : the coordinate frame (typically axes.transData)

        - sizex,sizey : width of x,y bar, in data units. 0 to omit

        - labelx,labely : labels for x,y bars; None to omit

        - pad, borderpad : padding, in fraction of the legend
        font size (or prop)

        - sep : separation between labels and bars in points.

        - **kwargs : additional arguments passed to base class

        constructor
        """

        ATBs = []
        
        for enum, xcor in enumerate(xcoords[1:]):
            width = xcoords[1] - xcoords[0]
            if enum % 2 == 0:
                fc = 'white'
            else:
                fc = 'black'


            Rect = Rectangle((0, 0), width, height, fc=fc,
                             edgecolor='k', zorder=99+enum)
            
            ATB = AuxTransformBox(transform)

            ATB.add_artist(Rect)

            xlabel = xlabels[enum]

            xlabel = int(xlabel)
            
            Txt_xlabel = TextArea(xlabel,
                                  textprops=dict(fontsize=fontsize),
                                  minimumdescent=True)

            # vertically packing a single stripe with respective label

            child = VPacker(children=[Txt_xlabel,
                                      ATB],
                            align="right", pad=5, sep=0)

            # TODO: add legend to the child
            # If we use ATBs.append(ATB), the resultant scalebar will have
            # no ticks next to each strap

            # If we use ATBs.append(child), there will be ticks. Though
            # there will be spaces between each strap.

            # While there is no solution for the problem, I am suggesting
            # the first case scenario

            # Therefore (TODO): add legend to the child
            ATBs.append(child)

        # horizontally packing all child packs in a single offsetBox

        Children = HPacker(children=list(ATBs),
                           align="right", pad=0, sep=0)

        Txt = TextArea('Km',
                       minimumdescent=False)

        child = VPacker(children=[Children, Txt],
                        align="center", pad=2, sep=2)

        AnchoredOffsetbox.__init__(self,
                                   loc='center left',
                                   borderpad=borderpad,
                                   child=child,
                                   prop=prop,
                                   frameon=False,
                                   **kwargs)


def _add_scalebar(ax, xcoords, height, xlabels=None,
                 ylabels=None, 
                 fontsize=4,
                 bbox_to_anchor=(0.2, 0.5),
                 bbox_transform='axes fraction',
                 **kwargs):
    """ Add scalebars to axes
    Adds a set of scale bars to *ax*, matching the size
    to the ticks of the plot
    and optionally hiding the x and y axes
    - ax : the axis to attach ticks to
    - matchx,matchy : if True, set size of scale bars to spacing
    between ticks
                    if False, size should be set using sizex and
                    sizey params

    - hidex,hidey : if True, hide x-axis and y-axis of parent

    - **kwargs : additional arguments passed to AnchoredScaleBars

    Returns
        created scalebar object
    """

    blended_transform = transforms.blended_transform_factory(
        ax.transData, ax.get_figure().dpi_scale_trans)

    sb = AnchoredScaleBar(ax,
                          blended_transform,
                          xcoords,
                          height,
                          xlabels=xlabels,
                          ylabels=ylabels,
                          fontsize=fontsize,
                          bbox_transform=ax.transAxes,
                          bbox_to_anchor=bbox_to_anchor,
                          **kwargs)

    sb.set_clip_on(False)
    ax.add_artist(sb)

    return sb


def get_unit_converter(unit):

    lookuptable = {'km': 1000,
                   'mi': 1.60934 * 1000,
                   'dm': 1e-1,
                   'cm': 1e-2,
                   'mm': 1e-3,
                   'um': 1e-6,
                   'nm': 1e-9}  # Miles to Km

    return lookuptable.get(unit, 'km')



def _point_along_line(ax, start, distance, projected=False, verbose=False):
    """Point at a given distance from start at a given angle.

    Args:
        ax:       CartoPy axes.
        start:    Starting point for the line in data coordinates.
        distance: Positive physical distance to travel in meters.
        angle:    Anti-clockwise angle for the bar, in degrees. Default: 0

    Returns:
        (lon,lat) coords of a point (a (2, 1)-shaped NumPy array)
    """

    # Direction vector of the line in axes coordinates.

    if not projected:

        geodesic = cgeo.Geodesic()

        Direct_R = geodesic.direct(start, 90, distance)

        target_longitude, target_latitude, forw_azi = Direct_R.base.T

        target_point = ([target_longitude[0], target_latitude[0]])

        actual_dist = geodesic.inverse(start,
                                       target_point).base.ravel()[0]
        if verbose:

            print('Starting point', start)

            print('target point', target_point)
            print('Expected distance between points: ', distance)

            print('Actual distance between points: ', actual_dist)

    if projected:

        longitude, latitude = start

        target_longitude = longitude + distance

        target_point = (target_longitude, latitude)

        if verbose:
            print('Axes is projected? ', projected)
            print('Expected distance between points: ', distance)

            print('Actual distance between points: ',
                  target_longitude - longitude)

    return start, target_point



def fancy_scalebar(ax,
                   bbox_to_anchor,
                   length,
                   unit_name='km',
                   dy=5,
                   max_stripes=5,
                   ytick_label_margins=0.25,
                   fontsize=8,
                   font_weight='bold',
                   rotation=45,
                   zorder=999,
                   paddings={'xmin': 0.1,
                             'xmax': 0.1,
                             'ymin': 0.3,
                             'ymax': 0.8},

                   bbox_kwargs={'facecolor': 'w',
                                'edgecolor': 'k',
                                'alpha': 0.7},
                   numeric_scale_bar=True,
                   numeric_scale_bar_kwgs={'x_text_offset': 0,
                                           'y_text_offset': -40,
                                           'box_x_coord': 0.5,
                                           'box_y_coord': 0.01},
                   verbose=False):
    '''
    Description

    ----------
        This function draws a scalebar in the given geoaxes.

    Parameters
    ----------
        ax (geoaxes):

        bbox_to_anchor (length 2 tuple):
            It sets where the scalebar will be drawn
            in axes fraction units.

        length (float):
            The distance in geodesic meters that will be used
            for generating the scalebar.

        unit_name (str):
            Standard (km).


        angle (int or float): in azimuth degrees.
            The angle that will be used for evaluating the scalebar.

            If 90 (degrees), the distance between each tick in the
            scalebar will be evaluated in respect to the longitude
            of the map.

            If 0 (degrees), the ticks will be evaluated in accordance
            to variation in the latitude of the map.

        dy (int or float):
            The hight of the scalebar in axes fraction.

        max_stripes (int):
            The number of stripes present in the scalebar.

        ytick_label_margins (int or float):
            The size of the margins for drawing the scalebar ticklabels.

        fontsize (int or float):
            The fontsize used for drawing the scalebar ticklabels.

        font_weight (str):
            the fontweight used for drawing the scalebar ticklabels.

        rotation (int or float):
            the rotation used for drawing the scalebar ticklabels.

        zorder(int):
            The zorder used for drawing the scalebar.

        paddings (dict):
            A dictionary defining the padding to draw a background box
            around the scalebar.

            Example of allowed arguments for padding:
                {'xmin': 0.3,
                 'xmax': 0.3,
                 'ymin': 0.3,
                 'ymax': 0.3}

        bbox_kwargs (dict):
            A dictionary defining the background box
            around the scalebar.

            Example of allowed arguments for padding:
                {'facecolor': 'w',
                 'edgecolor': 'k',
                 'alpha': 0.7}

        numeric_scale_bar(bool):
            whether or not to draw a number scalebar along side the
            graphic scalebar. Notice that this option can drastically
            vary in value, depending on the geoaxes projection used.

        numeric_scale_bar_kwgs (dict):
            A dictionary defining the numeric scale bar.

            Example of allowed arguments:
                {'x_text_offset': 0,
                 'y_text_offset': -40,
                 'box_x_coord': 0.5,
                 'box_y_coord': 0.01}

    Returns
    ----------
    None
    '''

    proj_units = ax.projection.proj4_params.get('units', 'degrees')
    if proj_units.startswith('deg'):
        projected = False

    elif proj_units.startswith('m'):
        projected = True

    # getting the basic unit converter for labeling the xticks
    unit_converter = get_unit_converter(unit_name)

    if verbose:
        print('Axes is projected? ', projected)

    # Convert all units and types.
   
    # Map central XY data coordinates
    x0, x1, y0, y1 = ax.get_extent()

    central_coord_map = np.mean([[x0, x1], [y0, y1]], axis=1).tolist()

    # End-point of bar in lon/lat coords.
    start, end = _point_along_line(ax,
                                   central_coord_map,
                                   length,
                                   projected=projected,
                                   verbose=verbose)

    # choose exact X points as sensible grid ticks with Axis 'ticker' helper
    xcoords = np.empty(max_stripes + 1)
    xlabels = []

    xcoords[0] = start[0]

    ycoords = np.empty_like(xcoords)

    for i in range(0, max_stripes):

        startp, endp = _point_along_line(ax, central_coord_map,
                                         length * (i + 1),
                                         projected=projected)

        xcoords[i + 1] = endp[0]

        ycoords[i + 1] = end[1]

        label = round(length * (i + 1) / unit_converter)

        xlabels.append(label)

    # Stacking data coordinates (the target ticks of the scalebar) in a list

    scalebar = _add_scalebar(ax, xcoords, dy, xlabels=xlabels,
                             ylabels=None,
                             fontsize=fontsize,
                             bbox_to_anchor=bbox_to_anchor)

    return scalebar, xlabels
    
if '__main__' == __name__:
    

    
    def test_scalebar():
        """Test"""

        fig, axes = plt.subplots(2, 2,
                                 subplot_kw={'projection':
                                             ccrs.Mercator()})
        
        projections = [ccrs.Mercator(), ccrs.PlateCarree(),
                       ccrs.Mercator(), ccrs.PlateCarree()]

        axes = axes.ravel()
        
        scalebars = []
        for enum, (proj, ax) in enumerate(zip(projections, axes)):
            ax.projection = proj
            ax.set_title(str(proj).split(' ')[0].split('.')[-1])
            
            if enum>=2:
                length = 200_000
            else:
                length = 2_000_000
            scalebar, xlabels = fancy_scalebar(ax,
                           bbox_to_anchor=(0.2, 0.2),
                           length=length,
                           unit_name='km',

                           max_stripes=4,
                           fontsize=10,
                           dy=0.05)
            
            scalebars.append(scalebar)
            
            gl = ax.gridlines(draw_labels=True)
        
            gl.top_labels = False
            gl.right_labels = False

            ax.stock_img()
            ax.coastlines()
            
            if enum>=2:
                ax.set_extent([-70, -45, -15, 10])
            
            
        
        plt.tight_layout()
        
        return axes, scalebars, xlabels

    axes, scalebars, xlabels = test_scalebar()

这是带有相应地图的结果图(每个地图都有给定的投影/扩展)。

注意比例尺补丁之间的间隙。

在相应比例尺内有间隙的地图


观察 1:

经过一些尝试,我注意到如果第 86 行(其中说明“ATBs.append(child)”)更改为“ATBs.append(ATB)”,则比例尺正确放置,没有间隙。

然而,如果这样做,比例尺会丢失每个相应补丁的所有刻度标签(黑色和白色的矩形)。


这是第二种情况的图:

请注意比例尺上方缺少刻度标签

使用正确的比例尺映射,但没有正确的刻度标签

感谢所有帮助。

真挚地,

标签: python-3.xmatplotlibcartopy

解决方案


推荐阅读