搞气象绘图的同学应该都有这种感觉:等值线、填色图跑出来不难,但一张图要能用、能发、能放进论文里,地图边界辅得准不准、陆地海洋分得清不清、有没有白化处理,才是真正拉开差距的地方。很多人在Basemap白化这一步被卡住,代码要么报错、要么白化区域不对,折腾半天图还是花的。这篇就专门讲怎么用Basemap叠加地图、给站点数据或网格场加白化效果,配合完整示例把每一步的原理也说清楚,看完可以直接拿去改自己的数据。
这篇内容适合两类人:一是刚接触气象绘图、想把站点或格点数据画到地图上的初学者;二是已经会用Basemap画基本填色图、但一直没搞明白白化边界和高低精度地图怎么选的老手。我会从环境配置讲到白化的原理,再给出一套能直接跑的示例代码,最后把常见的坑列出来,避免你再走弯路。需要说明的是,Basemap本身已停止维护,但它至今在气象和海洋圈里仍有大量存量脚本,学会它再迁移到Cartopy会轻松很多,所以这篇不会让你白学。
1. 整体设计思路:为什么要在地图上做白化
1.1 白化到底解决什么问题
先看一个最典型的场景:你有一堆站点观测的降水数据,插值成0.5度格点,然后用Basemap画出等值线填色图。如果直接画,你会发现陆地上有颜色、海洋上也有颜色,甚至国界线以外还有一大片平滑插值出来的虚假降水区。气象上这叫“越界表现”,本质是插值算法不认地理边界,它只认经纬度坐标,而你的站点数据大多在陆地上,插出来却把海洋也覆盖了。
白化的作用就是在绘图结束之后,把指定地理区域(通常是中国陆地、某个省或者某个流域)以外的图形元素全部裁掉。它相当于给地图加了一个“蒙版”,只让目标区域内的填色、等值线显示出来。实际效果就是:陆地边界以北、海岸线以外那些颜色块全部消失,只剩下研究区域内的数据,图面干净得多,也严谨得多。
1.2 Basemap绘图流程里白化的位置
很多初学者以为白化是一个独立步骤,其实不是。完整的Basemap绘图链条是这样的:先建立地图投影和经纬度范围,再绘海岸线、国界、省界等底图要素,然后把数据画上去(填色图或等值线),最后才是白化。白化必须发生在数据绘制之后、保存图像之前,这个顺序不能反。
为什么不能先白化再画数据?因为白化操作本质上是“把超出边界的内容用白色覆盖”,而填色图的pcolormesh或contourf是像素网格,只要绘图函数执行一次,新内容就会盖在旧内容上面。如果先白化再画数据,等于白化层被数据层覆盖了,白化就完全失效。这个原理搞清楚,后面调参就不会莫名其妙。
1.3 为什么边选Basemap边学它怎么迁移
Basemap在2020年前后停止了更新,很多新用户担心学了立刻过时。但现实是,大量老项目、经典教材、科研机构内部脚本都基于Basemap,你接手别人代码的时候大概率会遇到。而且Basemap的投影参数、海岸线分辨率、白化思路和Cartopy高度相近,把Basemap的十来个核心参数吃透,再看Cartopy文档几乎没有障碍。
我个人的建议是:做快速出图、临时诊断分析用Basemap完全够用,尤其你只是想看个空间分布,不用为了新而新。但是在新项目里我会优先给你推荐Cartopy,原因后面章节会提到。这篇还是以Basemap为主线,因为它的资料更全、坑也已经被前人踩得差不多了,适合用来建立完整的绘图思维。
2. 白化核心细节:边界数据、投影方式与掩膜接口
2.1 白化的本质是掩膜,不是裁剪
先说一个关键认知:Basemap白化不是真的把图形“剪掉”,而是通过内置的掩膜函数把目标区域之外的所有网格值设为NaN或者填充白色。底图本身没变,变的只是数据层的外观。所以白化后的图放大看,边界是锯齿状还是平滑的,取决于你给掩膜函数提供的边界精度和数据网格分辨率。
这个机制带来的直接影响是:白化边界的精细程度不完全由Basemap控制,更多由你传入的边界文件决定。Basemap支持读取shp格式的边界文件,也支持读取内部带的高精度海岸线数据。如果你用默认的低精度海岸线做白化,生成的蒙版边界会非常粗糙;想精细就得切换到高分辨率数据或者自备省级shp文件。
2.2 投影方式影响白化边界匹配
Basemap支持几十种投影,常见的有:
cyl:等距圆柱投影,经纬度直接映射为x和y坐标,简单直观,适合全球图或低纬度区域的白化;lcc:兰伯特等角圆锥投影,中纬度区域形状保留好,国内气象业务图常用;merc:墨卡托投影,适合赤道附近和小区域图;aea:阿尔伯斯等积投影,面积关系准确,适合做区域统计图;npaeqd、spaeqd等极地方位投影,适合极区。
白化时最容易踩的坑就是投影不匹配。掩膜函数接受的是经纬度边界,Basemap内部会先用你建立地图时定义的投影把经纬度转换到平面坐标,再生成蒙版。如果你建立地图用的投影和边界文件本身坐标不一致,或者你的数据经纬度范围跟边界文件范围差很多,白化结果就会出现错位、偏移甚至整个蒙版乱掉。
所以正确的做法是:先确定你画图用的投影,再读取对应投影下的地图实例,然后才生成白化蒙版。Basemap官方文档里给出的drawcountries()、drawcoastlines()这些方法内部都使用了你初始化Basemap时的投影参数,白化接口maskoceans和readshapefile也会自动做投影变换,理论上只要都走同一套Basemap实例,投影不会出问题。真正出问题的是你在白化时直接把经纬度数组传给掩膜函数,而没有经过basemap()转换。
2.3 边界数据的选择:内置海岸线、国界线、省级线还是定制shp
这是白化效果差异最大的来源。Basemap内置了三个分辨率等级的海岸线和国界:low、intermediate、high。对应参数是resolution='l'、'i'、'h'。其中'h'精度最高但初始化速度最慢,文件也大。如果你的图只是看看整体形势,'l'就够了;要出论文图,至少用'i';特定区域放大图建议直接用'h'或者定制shp。
国内的省级边界shp并不包含在Basemap默认包里,通常需要自己下载或从项目里获取。白化到省级边界时,我建议直接用readshapefile()读入shp文件,再拿到它的几何边界去调掩膜接口。这样你既可以画省界,又可以按省界白化,一举两得。
常用边界数据对比
| 边界来源 | 精度 | 适用场景 | 获取难度 | 白化效果 |
|---|---|---|---|---|
Basemap内置'l' | 低 | 全球/大区域趋势图 | 零成本 | 边界粗糙,适合示意 |
Basemap内置'i' | 中 | 全国范围、省级范围 | 零成本 | 边界可用,缩小后不明显 |
Basemap内置'h' | 高 | 区域放大图、论文插图 | 零成本但初始化慢 | 边界较精细 |
| 定制shp(省级/流域) | 最高 | 特定省、流域、自定义边界 | 需自行下载整理 | 最准确,推荐 |
2.4 白化最小实现原理
Basemap白化最常见的实现是用maskoceans()切掉海洋,或者用readshapefile()结合自定义映射来掩膜特定区域。核心思路是构建一个与你的数据网格相同形状的蒙版数组,区域内为True或非NaN,区域外为False或NaN,然后用numpy.ma数组去画图。
关键点是蒙版数组的经纬度必须和数据网格的经纬度对齐,否则你画的图会出现边界错位。实际操作中,我习惯把网格的经度、纬度先用np.meshgrid()生成二维网格,再传入basemap()转成投影坐标,绘图时把数据和蒙版都基于同一套网格数组,这样网格对齐的问题就消除了一大半。
3. 实操示例:一个完整的气象站点降水白化图
3.1 环境准备与依赖安装
Basemap的安装一直是个老话题。Python 3.8以前的版本装起来比较简单,直接用conda即可:
conda install basemap conda install basemap-data-hiresbasemap-data-hires是高分辨率数据包,强烈建议安装,没有它你用resolution='h'初始化会直接报错,或者运行时提示找不到数据文件。如果用的是Python 3.9及以上版本,Basemap可能没有预编译包,你需要编译安装或者直接改用Cartopy。这里有个很实用的替代方案:不用系统包管理器,直接用pip装geos和pyproj的依赖,再手动装Basemap源码,但极其容易踩坑,不推荐新手尝试。
我实测比较稳的一条路是用conda新建一个Python 3.8的虚拟环境专门跑Basemap老脚本:
conda create -n plot_env python=3.8 conda activate plot_env conda install basemap basemap-data-hires matplotlib numpy scipy这样装完,基本上不会遇到GEOS库缺失的问题。
3.2 数据准备:站点降水数据插值
示例里我们用模拟的站点降水数据,站点坐标假设覆盖某个区域,并用scipy.interpolate.griddata插值到网格上。为什么要先插值?因为白化是对网格数据操作的,站点散点没法直接做掩膜。
import numpy as np from scipy.interpolate import griddata lons = np.array([...]) # 站点经度列表 lats = np.array([...]) # 站点纬度列表 prec = np.array([...]) # 对应降水量,单位mm # 构建目标网格 grid_lon, grid_lat = np.meshgrid(np.linspace(80, 130, 200), np.linspace(15, 50, 160)) # 插值到网格上 grid_prec = griddata((lons, lats), prec, (grid_lon, grid_lat), method='cubic')插值方法的选择会影响白化后的图面:cubic平滑但可能在站点稀疏区产生极大值或负值;linear更保守;nearest边界明显,适合站点极密的时候用。业务上我最常用linear,在速度和合理性之间最平衡。
3.3 Basemap初始化与底图要素
插值完成后,建立Basemap实例。这里我用兰伯特投影画某个中纬度区域,投影中心定在研究区域中间。
from mpl_toolkits.basemap import Basemap import matplotlib.pyplot as plt fig = plt.figure(figsize=(10, 8)) ax = fig.add_axes([0.1, 0.1, 0.8, 0.8]) m = Basemap(projection='lcc', llcrnrlon=80, llcrnrlat=15, urcrnrlon=130, urcrnrlat=50, lat_0=32, lon_0=104, resolution='i', ax=ax) m.drawcoastlines(linewidth=0.5) m.drawcountries(linewidth=0.8) m.drawparallels(np.arange(15, 55, 10), labels=[1, 0, 0, 0], fontsize=10) m.drawmeridians(np.arange(80, 135, 10), labels=[0, 0, 0, 1], fontsize=10)这里resolution='i'是中精度,全国范围显示时线条粗细和清晰度比较合适。注意labels参数控制纬线经线标注的位置,[1,0,0,0]表示只在左边界标注纬度,[0,0,0,1]表示只在底边界标注经度,这个不设的话图上没有刻度字,很多人第一次画出来图干巴巴就是漏了这步。
3.4 投影坐标转换与填色绘图
画填色图前,必须把经纬度网格投影成Basemap平面坐标:
x, y = m(grid_lon, grid_lat) cf = m.contourf(x, y, grid_prec, cmap='Blues', levels=np.arange(0, 101, 10))注意这里传入contourf的x和y必须是二维投影坐标,不能再传经纬度。Basemap的m(...)调用会自动做投影变换。很多报错“x and y must be 2D arrays”其实是因为你传的是一维经纬度,没有先meshgrid。
3.5 白化关键步骤:基于省级shp裁掉区外数据
到这一步,图上海洋和国界外都有填色,开始白化。读取省界shp文件,然后生成掩膜。
from matplotlib.patches import Polygon from matplotlib.collections import PatchCollection # 投影读取shp m.readshapefile('path/to/china_province.shp', 'province', drawbounds=True) # 构建多边形列表,用于掩膜 province_polys = [] for info, shape in zip(m.province_info, m.province): province_polys.append(Polygon(shape, True)) # 制作掩膜网格:先创建一个全False的二维布尔数组 mask_grid = np.zeros(grid_prec.shape, dtype=bool) for poly in province_polys: # 将多边形顶点投影到Basemap坐标 poly_xy = np.array(poly.get_xy()) # 用matplotlib的Path判断网格点是否在多边形内部 from matplotlib.path import Path poly_path = Path(poly_xy) # 把投影坐标的网格点拉平判断 grid_points = np.vstack([x.ravel(), y.ravel()]).T inside = poly_path.contains_points(grid_points).reshape(grid_prec.shape) mask_grid = mask_grid | inside # 白化:区域外置为NaN grid_prec_masked = np.ma.masked_where(~mask_grid, grid_prec) # 重新绘制填色图 cf2 = m.contourf(x, y, grid_prec_masked, cmap='Blues', levels=np.arange(0, 101, 10))这段代码是白化的核心。思路很清楚:先读shp,把所有省级多边形拼成一个大的几何集合;然后判断每个网格中心点是否落在集合内;落在集合内的保留,落在集合外的用np.ma.masked_where屏蔽成透明(NaN)。最后重新画一次填色图,被屏蔽的区域就是空白,露出底图颜色。
不要在地图绘制后再用plt.fill()直接填白,那个治标不治本,而且会把海岸线盖掉,图放大看非常假。掩膜才是规范做法。
3.6 添加数据范围色标与保存
白化完成后,补上色标和经纬度信息,然后输出图片:
cb = m.colorbar(cf2, location='right', size='5%', pad='2%') cb.set_label('Precipitation (mm)') plt.title('Gridded Precipitation with Basemap Mask', fontsize=14) plt.savefig('precip_masked.png', dpi=300, bbox_inches='tight') plt.show()保存时带bbox_inches='tight'可以避免图例或标题被裁掉。dpi至少设150,提交论文用300。
3.7 完整代码串起来跑一遍
把上面代码按顺序整合,就是一个可运行的白化示例。实际使用中,你只需要替换站点数据、经纬度和shp路径。我这边经常把这段封装成函数,入参是站点经纬度、站点值、shp路径、研究区域范围,返回图形对象,这样后面批量出图会省很多时间。
4. 常见错误排查与避坑经验
4.1 白化后边界有锯齿或错位
这个最常见。原因基本只有一个:掩膜网格的分辨率和数据网格不一致。很多人做掩膜时用的是网格点坐标,但画图时用了插值后的另一套网格,两边网格数量、经纬度范围不一样,边界自然对不上。
解决办法是统一:grid_lon, grid_lat, grid_prec, mask_grid都必须来自同一套meshgrid,不能一会用200x160一会用150x150。
如果边界还是粗糙,再把网格加密,比如从200x160改成400x320。网格越密,边界锯齿越不明显,但计算量和内存也上升。
4.2 shp读取时报错:shapefile does not contain any polygons
这个坑我也踩过。Basemap的readshapefile()对shp几何类型有要求,它只认Polygon和MultiPolygon,如果你的shp里混入了LineString或Point类型的要素,就会报错或者跳过。
排查办法是用相关库查看shp类型,如果混线,就用GIS软件或者QGIS把面要素单独导出为新的shp。还有一种情况是shp文件缺少.dbf或.shx,Basemap读取时会失败,三个同名文件必须放在一起。
4.3maskoceans白化海洋时内陆湖泊也被遮掉
Basemap的maskoceans虽然方便,但内部依据的是全球海洋掩膜数据集,它不区分海洋和内陆湖泊,甚至有些大型水库也可能被当成水体遮掉。如果你研究区域有重要的内陆湖泊,我不建议用maskoceans,而是用定制的polygon去掩膜。高精度海洋白化还可能导致边界附近的小岛被误删,这在沿海站点数据里比较麻烦。
提示:
maskoceans只适合快速查看分布趋势,不适合出正式的精细化图。
4.4resolution='h'初始化极慢甚至卡死
高分辨率地图数据文件本身就大,初始化慢是正常的。如果你只是画一张全国范围的大趋势图,完全没必要用'h','l'或者'i'足够。真需要精细边界,建议只把图缩放到研究区域后,再启用'h',不要在初始化阶段就把全全球的高清边界加载进来。
另外,resolution='h'必须装basemap-data-hires,否则会运行时报错。
4.5 白化后fig.savefig保存的图有白色边框但不透明
白色边框通常是savefig的透明设置问题。白化区域本身是透明的,底层坐标轴背景默认是白色,所以视觉上确实是白色。如果你要把图片叠加到别的图上,注意设置transparent=True:
plt.savefig('precip_masked.png', dpi=300, transparent=True)这样白化区域就真正透明了,方便后续在GIS软件或PPT里继续叠加要素。
4.6 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 白化完全失效 | 白化后再画了数据 | 调整顺序:先画数据再白化 |
| 白化边界明显错位 | 蒙版网格与数据网格不一致 | 统一meshgrid来源 |
| 海洋白化误删湖泊 | 使用了maskoceans | 改用shp polygon掩膜 |
| 边界锯齿感强 | 网格太粗 | 提高网格分辨率 |
| 读shp报“no polygons” | shp含线/点要素 | 导出为纯面要素shp |
resolution='h'报错 | 未装hires数据包 | 安装basemap-data-hires |
| 图片白边处不透明 | 未设置transparent | savefig加transparent=True |
5. 从Basemap平滑迁移到Cartopy的白化思路
5.1 为什么推荐新项目考虑Cartopy
前面说了Basemap停维护,新环境安装困难,而且对新版matplotlib的兼容时有小毛病。Cartopy是现在气象绘图的主流替代方案,底层用PROJ做投影,接口更干净,还支持多种数据源叠加。
但它的白化方式和Basemap有本质区别。Basemap通过掩膜数组让绘图函数看不到区域外的数据;Cartopy则更底层,直接利用feature和几何裁剪,把图形对象按地理边界切割。理解Basemap的掩膜思路后,再看Cartopy的clip操作会非常顺畅,因为核心都是“只保留研究区域内的绘图对象”。
5.2 Cartopy里的白化等价实现
Cartopy本身不直接提供“把pcolormesh按shp裁剪”的高级函数,需要用shapely配合做裁剪。基本步骤是:
- 用
matplotlib.collections.PatchCollection绘制填色图; - 用
shapely.geometry读取shp并转成Polygon; - 去
matplotlib.path.Path做裁剪; - 把裁剪后的patch替换原图元素。
这其实和Basemap里用Path.contains_points做掩膜是同一个数学思想。掌握了Basemap这套,转到Cartopy就是换个壳。
5.3 过渡期两个库混用的细节
有些老代码库里既有Basemap又有Cartopy,混用时要特别注意坐标系统。Basemap用x, y平面投影坐标,Cartopy里大多直接用经纬度坐标后交给坐标轴投影变换,两者不兼容。不要在同一个axes上同时用Basemap和Cartopy的绘图函数,不然图面会乱掉。
建议一开始就把项目按新库统一,老脚本只在维护旧图时跑Basemap。这样既能复用历史经验,又能保证新图的可维护性。
6. 延伸技巧:批量出图、多子图与自定义白化区域
白化的应用远不止“画一张省界内的降水图”。在实际工作中,我经常要批量给多个省份、多个时次出图,这时候可以用循环批量处理。
6.1 按指定省份列表循环出图
比如我只想对华东六省一市出图,就可以在循环里读取相应的省名,然后动态拼接shp路径,逐省生成掩膜并保存。关键点:
- 每个省份单独出图时,地图范围要自动根据该省的经纬度范围调整,避免空白过多;
- 经纬网格范围可以取该省边界外扩2度,这样白化后图形不会贴在边界上;
- 文件名用省名加时间戳命名,便于归档。
6.2 多子图白化的细节
如果需要在一张图上画多个子图,每个子图投影不同,白化逻辑也要分别设置。Basemap的实例是绑定坐标轴的,你不能在一个坐标轴上画完再直接复制到另一个坐标轴。
正确做法是:
fig, axes = plt.subplots(2, 2, figsize=(16, 12)) for ax_index, ax in enumerate(axes.flat): m = Basemap(projection='lcc', ... , ax=ax) # 后续绘图、白化、添加色标都在ax上下文中注意不同子图如果数据范围差异大,色标不可共用,否则颜色映射会失真。我的习惯是每个子图单独出色标,或者用共享色标但设置统一的levels。
6.3 自定义白化区域:流域、城市群甚至任意手绘边界
shp不止能读省界,还能读流域边界、城市群范围、农业区划等任意多边形边界。白化的原理不变,只要把多边形的顶点坐标传进去就行。我甚至见过有人手动画一个不规则关注区,存成shp之后用来做白化,效果出奇地好。
如果你不想依赖外部shp,也可以用Basemap内置的drawmapboundary和一系列几何函数构建简单的圆形或矩形掩膜。方法是用matplotlib.path.Path生成一个圆形或者矩形边界,再走同样的contains_points判断流程。这个方法适合快速测试,但正式图还是用shp更严谨。
7. 性能优化与出图规范建议
7.1 网格越细并非越好
有人为了白化边界平滑,把网格加到2000x2000,结果一张图跑几十秒,电脑风扇狂转。其实白化边界的平滑程度主要受边界文件精度影响,而不是只靠无限提高网格密度。你先检查shp本身的顶点数,如果一个shp只有几千个点,网格提高到一定程度后边界也就那样。
实践里全国范围用300x200左右网格,省级范围用400x300左右,已经能出很干净的图。如果边界仍有锯齿,优先换更高精度的shp文件,而不是盲目加密网格。
7.2 出图尺寸、分辨率与字体
气象论文图一般要求半栏图宽度7-8cm,全栏图15cm左右,dpi不低于300。字体大小建议坐标轴刻度10pt,标题12pt,色标标签10pt,这样缩放到出版尺寸后依然可读。
Basemap默认字体是DejaVu Sans,中文字符会显示为方框。如果需要中文标签,要么用rcParams指定中文字体,要么尽量用英文标签。我在实际项目里为了避免字体问题,大多直接输出英文标签,然后在排版软件里统一加中文注释,效率更高。
7.3 色彩方案选择
降水图常用Blues或BrBG,温度图常用RdBu_r或coolwarm,风场图常用viridis。Basemap不限制你用的colormap,和matplotlib一致。色标的levels尽量设成整数且等间距,这样图例简洁,读者容易读数值。
我个人比较喜欢用等间距的levels=np.arange(0, 101, 10),因为白化后每个色块边界清晰,不会出现颜色过渡带里夹杂白化边界的错觉。
8. 最后再分享一点实际经验
Basemap白化并不神秘,核心就是“多边形包含判断”加“掩膜数组”。你只要理解透Path.contains_points()这一步,后面所有变体都只是数据源或者边界精度的不同。我踩过最深的坑就是最初用fill方法涂白,导致图上一块块白斑,后来才明白要返回到数据层去处理。
如果后续要做更复杂的专题图,比如把站点数据插值后叠加地形阴影、遥感影像或者风场箭头,Basemap依然能应付大部分场景。但新项目我强烈建议逐步迁移到Cartopy,因为维护性和现代Python生态兼容性更好。Basemap这篇内容能帮你建立的地理边界处理思维,迁移过去一样适用。
建议你动手改示例代码时,先用模拟数据把白化跑通,再换自己的shp和站点数据。这样排错时思路会清晰很多,也不容易把自己的数据问题和代码问题混在一起。