title: How to create a scalebar using cartopy and matplotlib?
body: <p>in respect to the previous <a href="https://stackoverflow.com/questions/32333870/how-can-i-show-a-km-ruler-on-a-cartopy-matplotlib-plot/50674451#50674451">examples</a> in stackoverflow, I searched for other alternatives in order to create a scalebar.</p>
  <p>In my research, I verified that the Basemap class from mpl_toolkits.basemap <a href="https://github.com/matplotlib/basemap/blob/3076ec9470cf7dba523bc94ebe5ae9a990e34d08/lib/mpl_toolkits/basemap/__init__.py#L591" rel="nofollow noreferrer">see here</a>. It has the &quot;drawmapscale&quot; method. This method has the option barstyle = 'fancy' for a more interesting scalebar drawing.</p>
  <p>Therefore, I attempted to convert the &quot;drawmapscale&quot; from the Basemap into a cartopy version.</p>
  <p>Nevertheless, the results were not positive, and I got error messages from the figure. I believe that the error is in the Transform of the data.</p>
  <p>Here is the script:</p>
  <hr />
  <pre><code>import numpy as np
  
  import matplotlib.pyplot as plt
  
  import pyproj
  
  import cartopy.crs as ccrs
  from matplotlib import is_interactive
  from cartopy.crs import (WGS84_SEMIMAJOR_AXIS, WGS84_SEMIMINOR_AXIS)
  from pyproj import Transformer
  
  class Scalebar():
      
      
      def __init__(self, 
                   ax,
                   suppress_ticks=True,
                   geographical_crs = 4326,
                    planar_crs = 5880,
                    
                   fix_aspect=True,
                   anchor='C',
                    celestial=False,
                    round=False,
                    noticks=False,
                    metric_ccrs=ccrs.TransverseMercator()):
          
          self.ax = ax
          self.fix_aspect = fix_aspect
          
          # setting metric ccrs for reference in the plotting
          
          self.metric_ccrs = metric_ccrs
          
  
          self.anchor = anchor
          # geographic or celestial coords?
          self.celestial = celestial
          # map projection.
          self.projection = ax.projection
          
          self.geographical_crs = geographical_crs
          self.planar_crs = planar_crs
          
  
          self._initialized_axes = set()
          
  
          self.round = round
          
          # map boundary not yet drawn.
          self._mapboundarydrawn = False
          
          self.rmajor = np.float(ax.projection.globe.semimajor_axis or WGS84_SEMIMAJOR_AXIS)
          self.rminor = np.float(ax.projection.globe.semiminor_axis or WGS84_SEMIMINOR_AXIS)
          
          # set instance variables defining map region.
          
          self.xmin = self.projection.boundary.bounds[0]
          self.xmax = self.projection.boundary.bounds[2]
          self.ymin = self.projection.boundary.bounds[1]
          self.ymax = self.projection.boundary.bounds[3]
          
          
          
          self._width = self.xmax - self.xmin
          self._height = self.ymax - self.ymin
          
          self.noticks = noticks
          
          
      def __call__(self,x,y,
                   
                   inverse=False
                   ):
          &quot;&quot;&quot;
          Calling the class instance with the arguments lon, lat will
          convert lon/lat (in degrees) to x/y map projection
          coordinates (in meters).
          
          If optional keyword ``inverse`` is True (default is False), 
          the inverse transformation from x/y to lon/lat is performed.
          
          Input arguments:
              lon, lat can be either scalar floats, sequences, or numpy arrays.
          &quot;&quot;&quot;
          
      
          if not inverse:
                  
              transformer = Transformer.from_crs(&quot;epsg:{0}&quot;.format(self.geographical_crs), 
                                                 &quot;epsg:{0}&quot;.format(self.planar_crs))
              
          else:
              transformer = Transformer.from_crs(&quot;epsg:{0}&quot;.format(self.planar_crs), 
                                                 &quot;epsg:{0}&quot;.format(self.geographical_crs))
          
          
          return transformer.transform(x, y)
  
      
      def drawmapscale(self, 
                       lon,
                       lat,
                       length,
                       lon0=None,
                       lat0=None,
                       barstyle='simple',\
                       units='km',
                       fontsize=9,
                       yoffset=None,
                       labelstyle='simple',\
                       fontcolor='k',
                       fillcolor1='w',
                       fillcolor2='k',\
                       format='%d',
                       zorder=None,
                       linecolor=None,
                       linewidth=None):
          &quot;&quot;&quot;
          Draw a map scale at ``lon,lat`` of length ``length``
          representing distance in the map
          projection coordinates at ``lon0,lat0``.
          .. tabularcolumns:: |l|L|
          ==============   ====================================================
          Keywords         Description
          ==============   ====================================================
          units            the units of the length argument (Default km).
          barstyle         ``simple`` or ``fancy`` (roughly corresponding
                           to the styles provided by Generic Mapping Tools).
                           Default ``simple``.
          fontsize         for map scale annotations, default 9.
          fontcolor            for map scale annotations, default black.
          labelstyle       ``simple`` (default) or ``fancy``.  For
                           ``fancy`` the map scale factor (ratio betwee
                           the actual distance and map projection distance
                           at lon0,lat0) and the value of lon0,lat0 are also
                           displayed on the top of the scale bar. For
                           ``simple``, just the units are display on top
                           and the distance below the scale bar.
                           If equal to False, plot an empty label.
          format           a string formatter to format numeric values
          yoffset          yoffset controls how tall the scale bar is,
                           and how far the annotations are offset from the
                           scale bar.  Default is 0.02 times the height of
                           the map (0.02*(self.ymax-self.ymin)).
          fillcolor1(2)    colors of the alternating filled regions
                           (default white and black).  Only relevant for
                           'fancy' barstyle.
          zorder           sets the zorder for the map scale.
          linecolor        sets the color of the scale, by default, fontcolor
                           is used
          linewidth        linewidth for scale and ticks
          ==============   ====================================================
          Extra keyword ``ax`` can be used to override the default axis instance.
          &quot;&quot;&quot;
          
          
          # get current axes instance (if none specified).
          ax = self.ax
  
          # convert length to meters
          lenlab = length
          if units == 'km':
              length = length*1000
          elif units == 'mi':
              length = length*1609.344
          elif units == 'nmi':
              length = length*1852
          elif units == 'ft':
              length = length*0.3048
          elif units != 'm':
              msg = &quot;units must be 'm' (meters), 'km' (kilometers), &quot;\
              &quot;'mi' (miles), 'nmi' (nautical miles), or 'ft' (feet)&quot;
              raise KeyError(msg)
          
          # Setting the center coordinates of the axes:
          
          xmin, xmax, ymin, ymax = self.ax.get_extent()
          
          if lon0 == None:
          
              lon0 = np.mean([xmin, xmax])
              
          if lat0 == None:
              lat0 = np.mean([ymin, ymax])
      
          
          # reference point and center of scale.
              
          x0,y0 = self(lon0,lat0)
          
          
          print('\n\n Central coords prior to transform')
          print('lon0,lat0: ', [lon0,lat0])
          
          print('\n\n central coordinates after transform')
          print('x0,y0: ', [x0,y0])
          
          
          
          xc,yc = self(lon,lat)
          
          
          
          print('\n\n positional coordinates prior to transform')
          print('lon, lat: ', [lon,lat])
          
          print('\n\n central coordinates after transform')
          print('xc,yc: ', [xc,yc])
          
          print('-'*20, '\n')
          
          # make sure lon_0 between -180 and 180
          lon_0 = ((lon0+360) % 360) - 360
          if lat0&gt;0:
              if lon&gt;0:
                  lonlatstr = u'%g\N{DEGREE SIGN}N, %g\N{DEGREE SIGN}E' % (lat0,lon_0)
              elif lon&lt;0:
                  lonlatstr = u'%g\N{DEGREE SIGN}N, %g\N{DEGREE SIGN}W' % (lat0,lon_0)
              else:
                  lonlatstr = u'%g\N{DEGREE SIGN}, %g\N{DEGREE SIGN}W' % (lat0,lon_0)
          else:
              if lon&gt;0:
                  lonlatstr = u'%g\N{DEGREE SIGN}S, %g\N{DEGREE SIGN}E' % (lat0,lon_0)
              elif lon&lt;0:
                  lonlatstr = u'%g\N{DEGREE SIGN}S, %g\N{DEGREE SIGN}W' % (lat0,lon_0)
              else:
                  lonlatstr = u'%g\N{DEGREE SIGN}S, %g\N{DEGREE SIGN}' % (lat0,lon_0)
          
          # left edge of scale
           
          lon1,lat1 = self(x0-length/2,y0, inverse=True)
          x1,y1 = self(lon1,lat1)
          # right edge of scale
          lon4,lat4 = self(x0+length/2,y0, inverse=True)
          x4,y4 = self(lon4,lat4)
          
          x1 = x1-x0+xc
          y1 = y1-y0+yc
          
          
          
          
          print('\n\n positional coordinates prior to transform')
          print('lon1,lat1: ', [lon1,lat1])
          
         
          
          print('\n\n positional coordinates prior to transform')
          print('x1, y1: ', [x1,y1])
          
          print() 
          
          print('\n\n central coordinates after transform')
          print('lon4,lat4: ', [lon4,lat4])
          
          print('-'*20, '\n')
          
          
          print('\n\n central coordinates after transform')
          print('x4,y4: ', [x4,y4])
          
          print('-'*20, '\n')
          
          
          x4 = x4-x0+xc
          y4 = y4-y0+yc
          if x1 &gt; 1.e20 or x4 &gt; 1.e20 or y1 &gt; 1.e20 or y4 &gt; 1.e20:
              raise ValueError(&quot;scale bar positioned outside projection limb&quot;)
          # scale factor for true distance
              
              
          gc = pyproj.Geod(a=self.rmajor,b=self.rminor)
          az12,az21,dist = gc.inv(lon1,lat1,lon4,lat4)
          scalefact = dist/length
          # label to put on top of scale bar.
          
          if labelstyle=='simple':
              labelstr = units
          
          elif labelstyle == 'fancy':
              labelstr = units+&quot; (scale factor %4.2f at %s)&quot;%(scalefact,lonlatstr)
          elif labelstyle == False:
              labelstr = ''
          else:
              raise KeyError(&quot;labelstyle must be 'simple' or 'fancy'&quot;)
          
          # default y offset is 2 percent of map height.
          if yoffset is None: 
              yoffset = 0.02*(self.ymax-self.ymin)
          
          rets = [] # will hold all plot objects generated.
          
          # set linecolor
          if linecolor is None:
              linecolor = fontcolor
          
          # 'fancy' style
          if barstyle == 'fancy':
              #we need 5 sets of x coordinates (in map units)
              #quarter scale
              lon2,lat2 = self(x0-length/4,y0,inverse=True)
              x2,y2 = self(lon2,lat2)
              x2 = x2-x0+xc; y2 = y2-y0+yc
              #three quarter scale
              lon3,lat3 = self(x0+length/4,y0,inverse=True)
              x3,y3 = self(lon3,lat3)
              x3 = x3-x0+xc; y3 = y3-y0+yc
              #plot top line
              ytop = yc+yoffset/2
              ybottom = yc-yoffset/2
              ytick = ybottom - yoffset/2
              ytext = ytick - yoffset/2
              
              lontext , lattext = self(lon0,ytext, inverse=True)
              
              #lon_top, lat_top = self(lon4,ytop,inverse=True)
              #lon_top, lat_bottom = self(lon4,ybottom,inverse=True)
              
              transform = self.metric_ccrs # this crs projection is meant to be for metric data
              
              rets.append(self.plot([x1,x4],
                                    [ytop,ytop],
                                    transform=transform,
                                    color=linecolor, 
                                    linewidth=linewidth)[0])
              
              #plot bottom line
              rets.append(self.plot([x1,x4],
                                    [ybottom,ybottom],
                                    transform=transform,
                                    color=linecolor, 
                                    linewidth=linewidth)[0])
              
              #plot left edge
              rets.append(self.plot([x1,x1],
                                    [ybottom,ytop],
                                    transform=transform,
                                    color=linecolor,
                                    linewidth=linewidth)[0])
              
              #plot right edge
              rets.append(self.plot([x4,x4],
                                    [ybottom,ytop],
                                    transform=transform,
                                    color=linecolor, 
                                    linewidth=linewidth)[0])
              
              #make a filled black box from left edge to 1/4 way across
              rets.append(ax.fill([x1,x2,x2,x1,x1],
                                  [ytop,ytop,ybottom,ybottom,ytop],
                                  transform=transform,
                                  ec=fontcolor,
                                  fc=fillcolor1)[0])
              
                  #make a filled white box from 1/4 way across to 1/2 way across
              rets.append(ax.fill([x2,x0,x0,x2,x2],
                                  [ytop,ytop,ybottom,ybottom,ytop],
                                  transform=transform,
                                  ec=fontcolor,
                                  fc=fillcolor2)[0])
                  
              #make a filled white box from 1/2 way across to 3/4 way across
              rets.append(ax.fill([x0,x3,x3,x0,x0],
                                  [ytop,ytop,ybottom,ybottom,ytop],
                                  transform=transform,
                                  ec=fontcolor,
                                  fc=fillcolor1)[0])
                  
              #make a filled white box from 3/4 way across to end
              rets.append(ax.fill([x3,x4,x4,x3,x3],
                                  [ytop,ytop,ybottom,ybottom,ytop],
                                  transform=transform,
                                  ec=fontcolor,
                                  fc=fillcolor2)[0])
                  
              #plot 3 tick marks at left edge, center, and right edge
              rets.append(self.plot([x1,x1],
                                    [ytick,ybottom],
                                    color=linecolor, 
                                    transform=transform,
                                    linewidth=linewidth)[0])
              
              rets.append(self.plot([x0,x0],
                                    [ytick,ybottom],
                                    transform=transform,
                                    color=linecolor, 
                                    linewidth=linewidth)[0])
              
              rets.append(self.plot([x4,x4],
                                    [ytick,ybottom],
                                    transform=transform,
                                    color=linecolor, 
                                    linewidth=linewidth)[0])
              
              #label 3 tick marks
              rets.append(ax.text(x1,lattext,format % (0),\
              horizontalalignment='center',\
              verticalalignment='top',\
              fontsize=fontsize,color=fontcolor))
              rets.append(ax.text(x0,lattext,format % (0.5*lenlab),\
              horizontalalignment='center',\
              verticalalignment='top',\
              fontsize=fontsize,color=fontcolor))
              rets.append(ax.text(x4,lattext,format % (lenlab),\
              horizontalalignment='center',\
              verticalalignment='top',\
              fontsize=fontsize,color=fontcolor))
              #put units, scale factor on top
              rets.append(ax.text(x0,ytop+yoffset/2,labelstr,\
              horizontalalignment='center',\
              verticalalignment='bottom',\
              fontsize=fontsize,color=fontcolor))
          # 'simple' style
          elif barstyle == 'simple':
              rets.append(self.plot([x1,x4],[yc,yc],color=linecolor, linewidth=linewidth)[0])
              rets.append(self.plot([x1,x1],[yc-yoffset,yc+yoffset],color=linecolor, linewidth=linewidth)[0])
              rets.append(self.plot([x4,x4],[yc-yoffset,yc+yoffset],color=linecolor, linewidth=linewidth)[0])
              rets.append(ax.text(xc,yc-yoffset,format % lenlab,\
              verticalalignment='top',horizontalalignment='center',\
              fontsize=fontsize,color=fontcolor))
              #put units, scale factor on top
              rets.append(ax.text(xc,yc+yoffset,labelstr,\
              horizontalalignment='center',\
              verticalalignment='bottom',\
              fontsize=fontsize,color=fontcolor))
          else:
              raise KeyError(&quot;barstyle must be 'simple' or 'fancy'&quot;)
          if zorder is not None:
              for ret in rets:
                  try:
                      ret.set_zorder(zorder)
                  except:
                      pass
          return rets
  
      def plot(self, *args, **kwargs):
          &quot;&quot;&quot;
          Draw lines and/or markers on the map
          (see matplotlib.pyplot.plot documentation).
          If ``latlon`` keyword is set to True, x,y are intrepreted as
          longitude and latitude in degrees.  Data and longitudes are
          automatically shifted to match map projection region for cylindrical
          and pseudocylindrical projections, and x,y are transformed to map
          projection coordinates. If ``latlon`` is False (default), x and y
          are assumed to be map projection coordinates.
          Extra keyword ``ax`` can be used to override the default axis instance.
          Other \**kwargs passed on to matplotlib.pyplot.plot.
          &quot;&quot;&quot;
          ax = self.ax
          self._save_use_hold(ax, kwargs)
          try:
              ret =  ax.plot(*args, 
                             **kwargs)
          finally:
              self._restore_hold(ax)
          # set axes limits to fit map region.
          self.set_axes_limits(ax=ax)
          # clip to map limbs
          ret,c = self._cliplimb(ax,ret)
          return ret
  
      def _save_use_hold(self, ax, kwargs):
          h = kwargs.pop('hold', None)
          if hasattr(ax, '_hold'):
              self._tmp_hold = ax._hold
              if h is not None:
                  ax._hold = h
  
      def _restore_hold(self, ax):
          if hasattr(ax, '_hold'):
              ax._hold = self._tmp_hold
      
      
      
      def set_axes_limits(self,ax=None):
          &quot;&quot;&quot;
          Final step in Basemap method wrappers of Axes plotting methods:
          Set axis limits, fix aspect ratio for map domain using current
          or specified axes instance.  This is done only once per axes
          instance.
          In interactive mode, this method always calls draw_if_interactive
          before returning.
          &quot;&quot;&quot;
          # get current axes instance (if none specified).
          ax = ax or self._check_ax()
  
          # If we have already set the axes limits, and if the user
          # has not defeated this by turning autoscaling back on,
          # then all we need to do is plot if interactive.
          if (hash(ax) in self._initialized_axes
                                   and not ax.get_autoscalex_on()
                                   and not ax.get_autoscaley_on()):
              if is_interactive():
                  import matplotlib.pyplot as plt
                  plt.draw_if_interactive()
              return
  
          self._initialized_axes.add(hash(ax))
          # Take control of axis scaling:
          ax.set_autoscale_on(False)
          # update data limits for map domain.
          corners = ((self.xmin, self.ymin), (self.xmax, self.ymax))
          ax.update_datalim(corners)
          ax.set_xlim((self.xmin, self.xmax))
          ax.set_ylim((self.ymin, self.ymax))
          # if map boundary not yet drawn for elliptical maps, draw it with default values.
  
          # make sure aspect ratio of map preserved.
          # plot is re-centered in bounding rectangle.
          # (anchor instance var determines where plot is placed)
          if self.fix_aspect:
              ax.set_aspect('equal',anchor=self.anchor)
          else:
              ax.set_aspect('auto',anchor=self.anchor)
          # make sure axis ticks are turned off.
          if self.noticks:
              ax.set_xticks([])
              ax.set_yticks([])
          # force draw if in interactive mode.
          if is_interactive():
              import matplotlib.pyplot as plt
              plt.draw_if_interactive()
      
      
      def _cliplimb(self,ax,coll):
          if not self._mapboundarydrawn:
              return coll, None
          c = self._mapboundarydrawn
          if c not in ax.patches:
              p = ax.add_patch(c)
              #p.set_clip_on(False)
          try:
              coll.set_clip_path(c)
          except:
              for item in coll:
                  item.set_clip_path(c)
          return coll,c
      
  </code></pre>
  <hr />
  <pre><code># now the test
  
  
  from cartopy.mpl.gridliner import LONGITUDE_FORMATTER, LATITUDE_FORMATTER
  
  import geopandas as gpd
  
  def get_standard_gdf():
      &quot;&quot;&quot; basic function for getting some geographical data in geopandas GeoDataFrame python's instance:
          An example data can be downloaded from Brazilian IBGE:
          ref: ftp://geoftp.ibge.gov.br/organizacao_do_territorio/malhas_territoriais/malhas_municipais/municipio_2017/Brasil/BR/br_municipios.zip    
      &quot;&quot;&quot;
      gdf_path = r'C:\my_file_path\Shapefile.shp'
  
      return gpd.read_file(gdf_path)
  
  def format_ax(ax, projection, xlim, ylim):
  
      ax.set_xlim(xlim)
      ax.set_ylim(ylim)
  
      ax.set_global()
      ax.coastlines()
  
  def main():
      
      
      fig = plt.figure(figsize=(8, 10))
  
      # Label axes of a Plate Carree projection with a central longitude of 180:
      
      #for enum, proj in enumerate(['Mercator, PlateCarree']):
      
      gdf = get_standard_gdf()
      
      xmin, ymin, xmax, ymax = gdf.total_bounds
      xlim = [xmin,  xmax]
      ylim = [ymin,  ymax]
      
      
      lon_c = np.mean(xlim)
      lat_c = np.mean(ylim)
      
      projection = ccrs.PlateCarree(central_longitude=0)
      
      ax1 = fig.add_subplot(3, 1, 1,
                            projection=projection,
                            xlim=[xmin, xmax],
                            ylim=[ymin, ymax])
      
      
      gdf.plot(ax=ax1, transform=projection)
      
      format_ax(ax1, projection, xlim, ylim)
      
      
      Grider = ax1.gridlines(draw_labels=True)
      Grider.xformatter = LONGITUDE_FORMATTER
      Grider.yformatter = LATITUDE_FORMATTER
      Grider.xlabels_top  = False
      Grider.ylabels_right  = False
      
      
      # Label axes of a Mercator projection without degree symbols in the labels
      # and formatting labels to include 1 decimal place:
      ax2 = fig.add_subplot(3, 1, 2, 
                            projection=ccrs.Mercator(),
                            xlim=[xmin, xmax],
                            ylim=[ymin, ymax])
      gdf.plot(ax=ax2, transform=projection)
      
      
      format_ax(ax2, projection, xlim, ylim)
      
      
      Grider = ax2.gridlines(draw_labels=True)
      Grider.xformatter = LONGITUDE_FORMATTER
      Grider.yformatter = LATITUDE_FORMATTER
      Grider.xlabels_top  = False
      Grider.ylabels_right  = False
      
      
      ax3 = fig.add_subplot(3, 1, 3, 
                            projection=ccrs.Robinson(central_longitude=lon_c,
                                                     #central_latitude=lat_c
                                                     ),
                            xlim=[xmin, xmax],
                            ylim=[ymin, ymax])
      
      gdf.plot(ax=ax3, transform=projection)
      
      format_ax(ax3, projection, xlim, ylim)
      
      
      ax3.set_xticks([-180, -120, -60, 0, 60, 120, 180])
      ax3.set_yticks([-78.5, -60, -25.5, 25.5, 60, 80])
  
      ax3.xaxis.set_major_formatter(LONGITUDE_FORMATTER)
      ax3.yaxis.set_major_formatter(LATITUDE_FORMATTER)
      plt.draw()
      return fig, fig.get_axes()
  
  if __name__ == '__main__':
      
      length = 1000
      
      fig, axes = main()
      
      gdf = get_standard_gdf()
      
      xmin, ymin, xmax, ymax = gdf.total_bounds
      xoff = 0.3 * (xmax - xmin)
      yoff = 0.2 * (ymax - ymin)
      
      for ax in axes:
          if hasattr(ax, 'projection'):
              x0, x1, y0, y1 = np.ravel(ax.get_extent())
              
              Scaler = Scalebar(ax=ax,
                                metric_ccrs=ccrs.Geodetic())
              Scaler.drawmapscale(lon = xmin+xoff, 
                                  lat = ymin + yoff,
                                  
                                  length=length,
                                  units = 'km',
                                  barstyle='fancy',
                                  yoffset=0.2 * (ymax - ymin)
                                  )
              
      fig.suptitle('Using Cartopy')
      fig.show()
      
      
  </code></pre>
  <hr />
  <p>When the above code is run, the scalebar is misplaced in the geoaxes. The scalebar xticks are misplaced, and its yaxis height proportion is also wrong.</p>
  <p>Here is an example: the geopandas is plotted in blue. Note that the scalebar is only visible in the second and third geoaxes.</p>
  <p><a href="https://i.stack.imgur.com/OMqpT.png" rel="nofollow noreferrer"><img src="https://i.stack.imgur.com/OMqpT.png" alt="Global view with three different projections" /></a></p>