本教程以 Lambert Conformal 投影为例,绘制在文献中常见的扇形地图。
就是上期冷锋降水的锋面图,参考文献的图是长这样的

起初小编以为这只是比较特别的投影,后面研究发现压根不是一回事
后面还是搜到了云台书史的教程,https://mp.weixin.qq.com/s/q69owfKQXsYcCX__uZjIsQ
解决了怎么画扇形的问题。但是后面对经纬度刻度怎么用最少的代码画上去就犯了难。
那么下面就记录一下小编迭代了哪些不同的绘制方式。
PlateCarree(经纬度)坐标系中仅采样南、北两条纬线,并由路径闭合操作形成东西两条经线边界。ax.set_boundary() 将该路径设为 GeoAxes 的边框。(经度, 南纬度))。Gridliner 自动求取网格线与扇形边框的交点,并以少量 xpadding/ypadding 将标签贴边放置。这样在改变图幅大小、子图位置或投影后,刻度仍会紧贴扇形边框。
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.path as mpath
import cartopy.crs as ccrs
import cartopy.feature as cfeature
from matplotlib.font_manager import FontProperties
plt.rcParams.update({
'font.family': 'serif',
'font.serif': ['Nimbus Roman', 'DejaVu Serif', 'SimHei', 'WenQuanYi Micro Hei'],
'font.size': 13,
'figure.dpi': 200,
'savefig.dpi': 300,
'axes.unicode_minus': False,
'axes.linewidth': 0.8,
})
# 地图显示范围:西、东、南、北
extent = [70, 140, 10, 60]
lonmin, lonmax, latmin, latmax = extent
data_crs = ccrs.PlateCarree()
map_crs = ccrs.LambertConformal(
central_longitude=105, standard_parallels=(30, 60)
)
Lambert 投影下,固定纬度线通常为弧线,固定经度线通常为直线。只需采样南、北两条纬线,再用 CLOSEPOLY 闭合路径,即可得到扇形边界;比四边都采样更简洁。
def make_sector_path(extent, step=1):
"""返回由两条纬线和两条经线围成的地理坐标路径。"""
west, east, south, north = extent
lons = np.arange(west, east + step, step)
vertices = np.vstack([
np.c_[lons, np.full_like(lons, south)], # 南侧纬线
np.c_[lons[::-1], np.full_like(lons, north)], # 北侧纬线
[west, south], # 闭合到起点
])
codes = ([mpath.Path.MOVETO]
+ [mpath.Path.LINETO] * (len(vertices) - 2)
+ [mpath.Path.CLOSEPOLY])
return mpath.Path(vertices, codes)
sector_path = make_sector_path(extent)
这是教程早期采用的写法:将四条边全部采样,再把顶点预先转换到目标投影。它更显式,适合边界并非规则经纬线的情况;对于普通经纬度矩形,上一种两条纬线加 CLOSEPOLY 的写法更短。
def make_projected_sector_path(extent, projection, source_crs, n_points=160):
west, east, south, north = extent
edge_lons = np.linspace(west, east, n_points)
edge_lats = np.linspace(south, north, n_points)
boundary_lons = np.concatenate([
edge_lons, np.full(n_points, east),
edge_lons[::-1], np.full(n_points, west),
])
boundary_lats = np.concatenate([
np.full(n_points, north), edge_lats,
np.full(n_points, south), edge_lats[::-1],
])
xy = projection.transform_points(source_crs, boundary_lons, boundary_lats)
return mpath.Path(xy[:, :2], closed=True)
# 使用时:ax.set_boundary(make_projected_sector_path(...), transform=map_crs)
更简洁的方案是直接使用 Cartopy 的 Gridliner。它会计算网格线与 ax.spines['geo'](即 set_boundary 设置的扇形边框)的交点,所以无需手写 ax.text()、转换坐标或计算边框法线。
指定 后,经度标签显示在扇形图的南侧边界,纬度标签显示在西侧边界及弧形边框上。
# 方法一完整代码:Cartopy Gridliner(推荐)
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.path as mpath
import cartopy.crs as ccrs
import cartopy.feature as cfeature
from matplotlib.font_manager import FontProperties
extent = [70, 140, 10, 60]
lonmin, lonmax, latmin, latmax = extent
data_crs = ccrs.PlateCarree()
map_crs = ccrs.LambertConformal(central_longitude=105, central_latitude=35)
lon_ticks = np.arange(lonmin, lonmax + 1, 10)
lat_ticks = np.arange(latmin, latmax + 1, 10)
lons = np.arange(lonmin, lonmax + 1, 1)
vertices = np.vstack([
np.c_[lons, np.full_like(lons, latmin)],
np.c_[lons[::-1], np.full_like(lons, latmax)],
[lonmin, latmin],
])
codes = ([mpath.Path.MOVETO]
+ [mpath.Path.LINETO] * (len(vertices) - 2)
+ [mpath.Path.CLOSEPOLY])
boundary = mpath.Path(vertices, codes)
fig = plt.figure(figsize=(7, 4.5), dpi=200)
ax = fig.add_subplot(1, 1, 1, projection=map_crs)
ax.set_extent(extent, crs=data_crs)
ax.set_boundary(boundary, transform=data_crs)
ax.add_feature(cfeature.LAND, facecolor='#f5f5f5')
ax.add_feature(cfeature.OCEAN, facecolor='#eaf4fb')
ax.add_feature(cfeature.COASTLINE, linewidth=0.7)
ax.spines['geo'].set_linewidth(1.2)
# Gridliner:经度标签在底部,纬度标签在左侧和弧形边框
gl = ax.gridlines(
crs=data_crs, draw_labels={'bottom': 'x', 'left': 'y', 'geo': 'y'},
xlocs=lon_ticks, ylocs=lat_ticks,
x_inline=False, y_inline=False, rotate_labels=False,
xpadding=1, ypadding=1,
color='gray', linewidth=0.5, linestyle='--', alpha=0.55,
)
gl.xlabel_style = {'size': 12}
gl.ylabel_style = {'size': 12}
ax.set_title('方法一:Cartopy Gridliner',
fontproperties=FontProperties(family='SimHei'), pad=18)
plt.show()

image
下面保留讨论中出现过的另外三种方案。为避免重复绘图代码,先定义一个只负责创建扇形底图的函数。
def create_sector_axes(title):
fig = plt.figure(figsize=(7, 4.5))
ax = fig.add_subplot(1, 1, 1, projection=map_crs)
ax.set_extent(extent, crs=data_crs)
ax.set_boundary(sector_path, transform=data_crs)
ax.add_feature(cfeature.LAND, facecolor='#f5f5f5')
ax.add_feature(cfeature.OCEAN, facecolor='#eaf4fb')
ax.add_feature(cfeature.COASTLINE, linewidth=0.7)
ax.spines['geo'].set_linewidth(1.2)
ax.gridlines(
crs=data_crs, draw_labels=False,
xlocs=lon_ticks, ylocs=lat_ticks,
color='gray', linewidth=0.5, linestyle='--', alpha=0.55,
)
ax.set_title(title, fontproperties=FontProperties(family='SimHei'), pad=18)
return fig, ax
ax.text() 加手工经纬度位置这种写法最直观,也最接近最初示例:先准备标签,再为每个标签人工指定一个经纬度位置。优点是可以精细控制每个标签;缺点是范围、投影或画布尺寸改变后通常需要重新调坐标。
# 方法二完整代码:ax.text() + 手工经纬度位置
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.path as mpath
import cartopy.crs as ccrs
import cartopy.feature as cfeature
extent = [70, 140, 10, 60]
lonmin, lonmax, latmin, latmax = extent
data_crs = ccrs.PlateCarree()
map_crs = ccrs.LambertConformal(central_longitude=105, central_latitude=35)
lon_ticks = np.arange(lonmin, lonmax + 1, 10)
lat_ticks = np.arange(latmin, latmax + 1, 10)
lons = np.arange(lonmin, lonmax + 1, 1)
vertices = np.vstack([
np.c_[lons, np.full_like(lons, latmin)],
np.c_[lons[::-1], np.full_like(lons, latmax)],
[lonmin, latmin],
])
codes = ([mpath.Path.MOVETO]
+ [mpath.Path.LINETO] * (len(vertices) - 2)
+ [mpath.Path.CLOSEPOLY])
boundary = mpath.Path(vertices, codes)
fig = plt.figure(figsize=(7, 4.5), dpi=200)
ax = fig.add_subplot(1, 1, 1, projection=map_crs)
ax.set_extent(extent, crs=data_crs)
ax.set_boundary(boundary, transform=data_crs)
ax.add_feature(cfeature.LAND, facecolor='#f5f5f5')
ax.add_feature(cfeature.OCEAN, facecolor='#eaf4fb')
ax.add_feature(cfeature.COASTLINE, linewidth=0.7)
ax.spines['geo'].set_linewidth(1.2)
ax.gridlines(
crs=data_crs, draw_labels=False, xlocs=lon_ticks, ylocs=lat_ticks,
color='gray', linewidth=0.5, linestyle='--', alpha=0.55,
)
labels = ([f'{tick}°N' for tick in lat_ticks]
+ [f'{tick}°E' for tick in lon_ticks])
# 这些位置仅针对当前范围和投影,改变参数后需要重新微调。
text_lons = [66, 65, 64, 63, 64, 63,
68, 78, 87.5, 96.5, 106, 116.5, 128, 137]
text_lats = [9.5, 18.5, 28.5, 38, 48.5, 58.5,
7, 7.25, 7, 7, 8, 7.8, 8, 8.2]
for x, y, label in zip(text_lons, text_lats, labels):
ax.text(x, y, label, fontsize=10, transform=ccrs.Geodetic(),
ha='center', va='center', clip_on=False)
ax.set_title('方法二:手工指定 ax.text() 位置', fontproperties=FontProperties(family='SimHei'), pad=18)
plt.show()

image
每个标签直接锚定在经纬线与边框的交点,然后只向外移动几 points。它比手工位置稳定,代码也不复杂;但偏移方向仍假设南边界向下、西边界向左。
# 方法三完整代码:边框地理坐标锚点 + 固定 points 偏移
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.path as mpath
import cartopy.crs as ccrs
import cartopy.feature as cfeature
extent = [70, 140, 10, 60]
lonmin, lonmax, latmin, latmax = extent
data_crs = ccrs.PlateCarree()
map_crs = ccrs.LambertConformal(central_longitude=105, central_latitude=35)
lon_ticks = np.arange(lonmin, lonmax + 1, 10)
lat_ticks = np.arange(latmin, latmax + 1, 10)
lons = np.arange(lonmin, lonmax + 1, 1)
vertices = np.vstack([
np.c_[lons, np.full_like(lons, latmin)],
np.c_[lons[::-1], np.full_like(lons, latmax)],
[lonmin, latmin],
])
codes = ([mpath.Path.MOVETO]
+ [mpath.Path.LINETO] * (len(vertices) - 2)
+ [mpath.Path.CLOSEPOLY])
boundary = mpath.Path(vertices, codes)
def add_fixed_tick(ax, lon, lat, label, offset, ha, va):
transform = data_crs._as_mpl_transform(ax)
ax.annotate(
'', xy=(lon, lat), xycoords=transform,
xytext=offset, textcoords='offset points',
arrowprops=dict(arrowstyle='-', lw=0.7, color='black'),
annotation_clip=False,
)
ax.annotate(
label, xy=(lon, lat), xycoords=transform,
xytext=(offset[0] * 1.7, offset[1] * 1.7),
textcoords='offset points', ha=ha, va=va, fontsize=10,
annotation_clip=False,
)
fig = plt.figure(figsize=(7, 4.5), dpi=200)
ax = fig.add_subplot(1, 1, 1, projection=map_crs)
ax.set_extent(extent, crs=data_crs)
ax.set_boundary(boundary, transform=data_crs)
ax.add_feature(cfeature.LAND, facecolor='#f5f5f5')
ax.add_feature(cfeature.OCEAN, facecolor='#eaf4fb')
ax.add_feature(cfeature.COASTLINE, linewidth=0.7)
ax.spines['geo'].set_linewidth(1.2)
ax.gridlines(
crs=data_crs, draw_labels=False, xlocs=lon_ticks, ylocs=lat_ticks,
color='gray', linewidth=0.5, linestyle='--', alpha=0.55,
)
for lon_tick in lon_ticks[1:]: # 跳过左下角,避免双标签重叠
add_fixed_tick(ax, lon_tick, latmin, f'{lon_tick}°E', (0, -3), 'center', 'top')
for lat_tick in lat_ticks:
add_fixed_tick(ax, lonmin, lat_tick, f'{lat_tick}°N', (-3, 0), 'right', 'center')
ax.set_title('方法三:边框锚点 + 固定偏移', fontproperties=FontProperties(family='SimHei'), pad=18)
plt.show()

image
该方案会在显示坐标中计算边框切线,再得到垂直于边框、朝向图外的法线。它可以画出真正垂直于弧形边框的短刻度线,适合对刻度方向要求严格的出版图;代价是辅助函数较长。
# 方法四完整代码:自动计算边框向外法线
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.path as mpath
import cartopy.crs as ccrs
import cartopy.feature as cfeature
extent = [70, 140, 10, 60]
lonmin, lonmax, latmin, latmax = extent
data_crs = ccrs.PlateCarree()
map_crs = ccrs.LambertConformal(central_longitude=105, central_latitude=35)
lon_ticks = np.arange(lonmin, lonmax + 1, 10)
lat_ticks = np.arange(latmin, latmax + 1, 10)
lons = np.arange(lonmin, lonmax + 1, 1)
vertices = np.vstack([
np.c_[lons, np.full_like(lons, latmin)],
np.c_[lons[::-1], np.full_like(lons, latmax)],
[lonmin, latmin],
])
codes = ([mpath.Path.MOVETO]
+ [mpath.Path.LINETO] * (len(vertices) - 2)
+ [mpath.Path.CLOSEPOLY])
boundary = mpath.Path(vertices, codes)
def add_normal_tick(ax, xy, tangent_points, label, tick_length=3, gap=2):
transform = data_crs._as_mpl_transform(ax)
point = transform.transform(xy)
p0, p1 = transform.transform(tangent_points)
tangent = p1 - p0
normal = np.array([-tangent[1], tangent[0]])
normal /= np.linalg.norm(normal)
center = transform.transform(((lonmin + lonmax) / 2, (latmin + latmax) / 2))
if np.dot(normal, point - center) < 0:
normal *= -1
normal_pt = normal * 72 / ax.figure.dpi
ha = 'left' if normal[0] > 0.25 else ('right' if normal[0] < -0.25 else 'center')
va = 'bottom' if normal[1] > 0.25 else ('top' if normal[1] < -0.25 else 'center')
ax.annotate(
'', xy=xy, xycoords=transform,
xytext=tuple(normal_pt * tick_length), textcoords='offset points',
arrowprops=dict(arrowstyle='-', color='black', lw=0.7),
annotation_clip=False,
)
ax.annotate(
label, xy=xy, xycoords=transform,
xytext=tuple(normal_pt * (tick_length + gap)), textcoords='offset points',
ha=ha, va=va, fontsize=10, annotation_clip=False,
)
fig = plt.figure(figsize=(7, 4.5), dpi=200)
ax = fig.add_subplot(1, 1, 1, projection=map_crs)
ax.set_extent(extent, crs=data_crs)
ax.set_boundary(boundary, transform=data_crs)
ax.add_feature(cfeature.LAND, facecolor='#f5f5f5')
ax.add_feature(cfeature.OCEAN, facecolor='#eaf4fb')
ax.add_feature(cfeature.COASTLINE, linewidth=0.7)
ax.spines['geo'].set_linewidth(1.2)
ax.gridlines(
crs=data_crs, draw_labels=False, xlocs=lon_ticks, ylocs=lat_ticks,
color='gray', linewidth=0.5, linestyle='--', alpha=0.55,
)
for lon_tick in lon_ticks[1:]:
add_normal_tick(
ax, (lon_tick, latmin),
[(lon_tick - 0.3, latmin), (lon_tick + 0.3, latmin)],
f'{lon_tick}°E',
)
for lat_tick in lat_ticks:
add_normal_tick(
ax, (lonmin, lat_tick),
[(lonmin, lat_tick - 0.3), (lonmin, lat_tick + 0.3)],
f'{lat_tick}°N',
)
ax.set_title('方法四:自动法线方向的刻度', fontproperties=FontProperties(family='SimHei'), pad=18)
plt.show()

image
方法 | 代码量 | 随投影和画布自动适配 | 可画严格垂直的短刻度线 | 推荐场景 |
|---|---|---|---|---|
Gridliner | 最少 | 是 | 否 | 一般地图,首选 |
手工 ax.text() | 中等 | 否 | 否 | 特殊版式、逐标签微调 |
固定偏移 annotate | 中等 | 基本可以 | 近似 | 固定投影和边界方向 |
自动法线 annotate | 最多 | 是 | 是 | 出版图、严格刻度方向 |
xpadding、ypadding;单位均为 points。设为 0 最贴边,负数会将标签放进图内。extent 后重新运行构造 sector_path 的单元格,并同步更新 lon_ticks、lat_ticks。ax 分别调用 ax.set_boundary(sector_path, ...) 和 ax.gridlines(...),不要再使用 fig.text()。Gridliner 负责边界交点和标签;若版式还要求短线,可在该交点上额外使用 ax.annotate(),但绝大多数地图只需网格线和贴边标签。