☰
地理信息可视化实战:basemap、cartopy与geopandas叠加地图数据
2026/10/27 6:01:19 网站建设 项目流程

地理信息可视化这个方向,我断断续续折腾了几年,从最早用桌面端软件出图,到后来转到代码驱动的工作流,中间踩过的坑能写满一个笔记本。这次想聊的是用 basemap 和 cartopy 配合 geopandas 来显示地图这件事,标题里带了个“03”,说明这是一个系列里的第三篇,前面大概率已经铺垫过环境搭建和基础绘图的内容,所以这篇我打算把重心放在“怎么把矢量数据和底图真正揉到一起”这个最让人头疼的环节上。

如果你手头有一份带地理坐标的表格数据,比如某类设施的分布点、某个区域的边界划分,想把它画到一张像样的地图上,那这套组合基本就是绕不开的路子。basemap 是老牌选手,cartopy 是它的接棒者,geopandas 则负责把矢量数据读进来、做空间运算。三者各管一段,配合好了能出很漂亮的图,配合不好就是一堆坐标系报错和空白画布。这篇文章适合已经能跑通基础绘图、但卡在“数据叠底图”这一步的朋友,也适合想系统理清这几个库分工的人。下面我按自己的实操顺序,把设计思路、核心细节、完整流程和排查经验一层层拆开讲。

1. 整体方案设计与选型思路拆解

1.1 为什么是这三个库的组合,而不是单一工具

先说清楚分工。geopandas 本质上是 pandas 的空间扩展,它把几何对象(点、线、面)塞进 DataFrame 里,让你能用处理表格的方式处理空间数据。它擅长的是数据读取、坐标转换、空间连接、裁剪这些“数据层”的活,但它本身不是绘图库,虽然它有个plot()方法能快速出图,可一旦你要叠加底图、加经纬网格、控制投影,它就力不从心了。

basemap 和 cartopy 都是“绘图层”的工具,负责把地图的骨架画出来——海岸线、国界、河流、地形底图这些。basemap 基于 matplotlib 早期生态,接口成熟但维护停滞,对新版本 Python 和依赖库的兼容性越来越差;cartopy 是后来者,投影系统设计更现代,和 matplotlib 的集成更干净,长期看是更稳妥的选择。标题里把两者并列,我的理解是:老项目里可能还在用 basemap,新项目建议直接上 cartopy,但两者的核心思路是相通的,都是“先定投影,再画要素,最后叠数据”。

那为什么不干脆用 geopandas 自带的绘图?因为它的plot()底层也是调 matplotlib,画出来的东西没有地理底图参照,读者根本不知道那块多边形在地球上哪个位置。地图可视化的价值一半在数据本身,一半在空间参照,缺了底图,信息量直接砍半。所以这个组合的逻辑是:geopandas 管数据,cartopy 管底图和投影,matplotlib 管最终渲染和样式微调,三者串起来才是一条完整的链路。

1.2 投影选择是整个流程的地基

我见过太多人上来就画图,结果数据位置全偏,回头排查半天发现是投影没统一。这里必须强调:地理坐标系(经纬度)和投影坐标系(平面米制)是两回事。geopandas 读进来的数据,crs属性可能是 EPSG:4326(WGS84 经纬度),而 cartopy 画图时需要指定一个投影对象,两者如果不匹配,数据就会飘到莫名其妙的位置。

我的习惯是:先看数据的原始 crs,再决定底图用什么投影。如果数据是全球尺度的,用 PlateCarree(等经纬度投影)最省事,虽然高纬度会变形,但胜在直观;如果是某个国家或区域尺度的,用 Albers 等积投影或者 Lambert 等角投影更合适,面积或形状至少有一个不失真;如果是极地数据,那就得上极射投影。选投影不是随便挑个好看的,而是要看你的数据要表达什么——强调面积对比就用等积,强调形状和方向就用等角,两者不可兼得,这是地图学的基本取舍。

实际操作里有个省事的做法:让 geopandas 的数据先统一到和 cartopy 投影一致的 crs 上,再传给绘图函数。具体就是gdf.to_crs(投影对应的epsg),或者用 cartopy 的project_geometry做动态转换。前者适合数据量大、需要反复绘制的场景,后者适合临时探索。我一般倾向后者,因为不用改动原始数据,试错成本低。

1.3 数据分层与绘制顺序的考量

一张地图上的元素是有层级的,画的时候顺序错了,要么被盖住,要么压住别人。我的固定顺序是:底图(海洋、陆地填充)→ 行政边界或海岸线 → 面状数据(区域填充)→ 线状数据(道路、河流)→ 点状数据(设施位置)→ 标注和网格。这个顺序的逻辑是从“背景”到“前景”,从“大范围”到“小细节”,保证重要的信息始终在最上层。

geopandas 的plot()有个ax参数,可以把多次绘图叠加到同一个坐标轴上,这就是分层的技术基础。但要注意,每次调用plot()时如果zorder没设好,matplotlib 会按调用顺序叠,容易出现点被面盖住的情况。我的经验是显式给每层设zorder,底图 0,边界 1,面 2,线 3,点 4,标注 5,这样不管调用顺序怎么变,层级都是稳的。

2. 核心细节解析与实操要点

2.1 环境依赖的版本匹配问题

这套组合最烦人的地方不是代码逻辑,而是依赖版本。cartopy 依赖 GEOS、PROJ、Shapely 这几个底层库,geopandas 也依赖它们,版本对不上就是各种ImportError或者运行时崩溃。我踩过最典型的一次是 Shapely 2.0 升级后,老版本 geopandas 的某些空间运算直接报错,排查了半天才发现是 ABI 不兼容。

我的建议是:用 conda 而不是 pip 来装这套东西。conda 的包管理会帮你把底层二进制依赖一起解决,pip 装 cartopy 经常卡在编译 GEOS 上。具体命令大致是conda install -c conda-forge geopandas cartopy,走 conda-forge 渠道,版本更新更及时。装完之后用conda list核对一下 shapely、pyproj、geos 的版本,确保它们之间没有明显冲突。如果非要用 pip,那就先把 wheel 包下好,别让它现场编译。

还有一个隐藏的坑:matplotlib 的版本。cartopy 对 matplotlib 的某些 API 有依赖,版本太新或太旧都可能出问题。我一般锁一个中间版本,比如 3.7 或 3.8,太激进容易翻车。

2.2 坐标系转换的关键操作

前面提了投影的重要性,这里说具体操作。geopandas 的to_crs()是最常用的转换方法,参数可以是 EPSG 代码,也可以是 proj 字符串。比如把 WGS84 转成 Web Mercator,就是gdf.to_crs(epsg=3857)。但要注意,to_crs()只对几何列生效,属性列不动,转换后原来的经纬度列如果还在,会和几何列不一致,容易误导。我的做法是转换后把冗余的经纬度列删掉,或者明确标注哪列是原始坐标。

cartopy 这边,投影是用ccrs.XXX()创建的对象,比如ccrs.PlateCarree()、ccrs.AlbersEqualArea()。绘图时通过transform参数告诉 cartopy 这个数据是什么坐标系,它会自动做投影变换。这里有个容易混淆的点:transform指的是数据的坐标系,不是目标投影。目标投影是在创建GeoAxes时通过projection参数定的。很多人把这两个搞反,结果数据位置全乱。

我整理了一个对照表,方便快速查:

场景数据原始 crscartopy 投影对象transform 参数
全球经纬度数据EPSG:4326ccrs.PlateCarree()ccrs.PlateCarree()
区域等积展示EPSG:4326ccrs.AlbersEqualArea(...)ccrs.PlateCarree()
已投影的米制数据EPSG:3857ccrs.Mercator()ccrs.Mercator()

关键原则:transform填的是数据“现在是什么”,projection填的是“要画成什么”,两者可以不同,cartopy 负责中间的换算。

2.3 底图要素的精细控制

cartopy 的feature模块提供了各种底图要素,比如BORDERS(国界)、COASTLINE(海岸线)、RIVERS(河流)、LAKES(湖泊)。这些要素默认是粗粒度的,画全球图够用,画区域图就显得太糙。要精细控制,得用NaturalEarthFeature指定分辨率和类别,比如scale='10m'表示 1:1000 万精度,scale='50m'是 1:5000 万。分辨率越高,数据量越大,绘制越慢,这个要权衡。

另一个细节是要素的样式。feature对象可以传facecolor、edgecolor、linewidth这些参数,但有些要素(比如陆地填充)需要配合add_feature的zorder才能正确叠在海洋下面。我一般先画海洋填充(OCEAN),再画陆地填充(LAND),最后画边界线,这样层次最干净。

注意:NaturalEarthFeature第一次调用时会自动下载数据到本地缓存,如果网络环境不好会卡住。可以提前手动下载放到缓存目录,或者用cartopy.config['data_dir']指定一个已有数据的路径。

2.4 geopandas 数据预处理的三件事

数据进绘图函数之前,我必做三件事。第一是检查 crs 是否为空,有些 shapefile 读进来 crs 是 None,直接画会报错,得手动set_crs()。第二是处理无效几何,用gdf.is_valid检查,无效的用buffer(0)修复,否则某些空间运算会崩。第三是裁剪到绘图范围,如果数据覆盖范围远大于地图范围,不裁剪会拖慢渲染,用gdf.clip(边界框)或者按total_bounds过滤。

这三步看起来琐碎,但能省掉后面大量莫名其妙的报错。我吃过亏,一份数据里混了几个自相交的多边形,画图时直接抛异常,定位了半天才发现是几何无效。

3. 完整实操流程与关键环节实现

3.1 从零搭一个可复现的绘图脚本

下面这套流程是我反复用过的模板,改改参数就能套到别的项目上。先讲数据准备,再讲底图搭建,最后讲叠加和出图。

第一步,读数据。用gpd.read_file()读 shapefile 或 GeoJSON,读进来先打印gdf.crs和gdf.head(),确认坐标系和字段没问题。如果 crs 是 None,用gdf = gdf.set_crs(epsg=4326)补上。

第二步,建画布。用plt.subplots()创建 figure 和 axes,但 axes 要用projection参数指定 cartopy 投影,比如:

import matplotlib.pyplot as plt import cartopy.crs as ccrs fig, ax = plt.subplots(figsize=(10, 8), subplot_kw={'projection': ccrs.PlateCarree()})

这里figsize按输出需求定,出报告用 10x8 够清晰,出海报就放大到 16x12。

第三步,设范围。用ax.set_extent([经度min, 经度max, 纬度min, 纬度max], crs=ccrs.PlateCarree())框定地图范围。范围要比数据范围略大一圈,留点边距,不然数据贴着边框不好看。

第四步,加底图要素。先ax.add_feature(cfeature.OCEAN, zorder=0),再ax.add_feature(cfeature.LAND, zorder=0),然后ax.add_feature(cfeature.BORDERS, linewidth=0.5, zorder=1)。如果区域小,换成NaturalEarthFeature的高分辨率版本。

第五步,叠 geopandas 数据。面数据用gdf.plot(ax=ax, column='某字段', cmap='viridis', alpha=0.7, zorder=2, transform=ccrs.PlateCarree()),点数据用gdf.plot(ax=ax, markersize=20, color='red', zorder=4, transform=ccrs.PlateCarree())。column参数可以按字段值做颜色映射,cmap选配色方案,alpha控制透明度让底图透出来。

第六步,加网格和标注。用ax.gridlines(draw_labels=True, linewidth=0.3, color='gray', alpha=0.5)加经纬网格,draw_labels=True会自动标经纬度。标注文字用ax.text()逐个加,注意transform也要设对。

第七步,出图。plt.savefig('output.png', dpi=300, bbox_inches='tight'),dpi300 是印刷级,屏幕看 150 够用,bbox_inches='tight'去掉多余白边。

3.2 参数计算:范围框和分辨率的取舍

范围框的确定有个小技巧。假设数据经度范围是 100 到 120,纬度 20 到 40,直接设这个范围数据会贴边。我的做法是各方向外扩 5%,经度扩到 99 到 121,纬度扩到 19 到 41。计算就是min - (max-min)*0.05和max + (max-min)*0.05。这个 5% 是经验值,数据密集就扩少点,稀疏就扩多点。

分辨率的选择要看输出尺寸。如果出图宽度 3000 像素,地图跨度 20 度经度,那每度约 150 像素,1:1000 万精度(10m)的数据在这个尺度下细节足够,再高就是浪费。反过来,如果出图只有 800 像素宽,用 10m 数据反而会因为细节过多显得杂乱,50m 更合适。这个换算不用精确算,凭经验估就行,核心是“数据精度匹配输出精度”。

3.3 实操现场:一次完整的叠图记录

我拿一份模拟的设施分布点数据跑一遍。数据是 CSV,带经度纬度两列,先转成 GeoDataFrame:

import pandas as pd import geopandas as gpd from shapely.geometry import Point df = pd.read_csv('facilities.csv') geometry = [Point(xy) for xy in zip(df['lon'], df['lat'])] gdf = gpd.GeoDataFrame(df, geometry=geometry, crs='EPSG:4326')

转完之后检查gdf.crs确认是 4326。然后按前面的流程建画布、设范围、加底图。叠点的时候我用了column='capacity'做颜色映射,cmap='YlOrRd',容量大的点偏红,小的偏黄,一眼能看出分布格局。

跑完第一版发现两个问题:一是底图的国界线太粗,压住了点;二是网格标签和地图边缘重叠。第一个问题把BORDERS的linewidth从默认调到 0.4 解决;第二个问题用ax.gridlines(draw_labels=True, x_inline=False, y_inline=False)让标签不内嵌,再配合plt.tight_layout()自动调整边距。改完第二版就干净了。

这个过程说明一个事:第一版图永远不完美,迭代是常态。别指望一次写对,先跑通再调样式,效率最高。

4. 常见问题与排查技巧实录

4.1 数据位置偏移的三种典型原因

数据画上去位置不对,是最常见的问题,原因基本逃不出三类。第一类是 crs 没设或设错,数据被当成经纬度直接画在投影坐标上,位置会偏到天边。排查方法:打印gdf.crs,和transform参数对比,不一致就转换。第二类是transform和projection搞反,前面说过,transform是数据坐标系,projection是目标投影,反了数据会扭曲。第三类是数据本身坐标就是错的,比如经纬度列顺序颠倒(lat 和 lon 写反),这种最隐蔽,得拿几个已知位置的点核对。

我整理了一个速查表:

现象可能原因排查动作
数据完全不见crs 为 None 或范围设错打印 crs,检查 set_extent
数据偏移几十度transform 与数据 crs 不符核对 transform 参数
数据形状扭曲projection 与 transform 混淆确认两者定义
数据上下颠倒经纬度列顺序反了抽查已知点坐标

4.2 渲染性能与内存问题

数据量大时,cartopy 画底图要素会非常慢,尤其是高分辨率的海岸线。我的优化手段有三个:一是降低底图分辨率,区域图用 50m 够用就别上 10m;二是把底图要素缓存成图片,下次直接贴图,不过这招牺牲了矢量清晰度;三是用ax.add_geometries()批量添加,比逐个add_feature快。geopandas 这边,数据超过几万条时,plot()会明显卡顿,可以先做空间聚合或抽样,或者用simplify()简化几何。

内存方面,cartopy 的投影变换会生成大量中间对象,图多了容易爆内存。我的习惯是每画完一张图就plt.close(fig),别让 figure 对象堆积。

4.3 中文字体与标注乱码

地图上加中文标注,十有八九遇到方块乱码。原因是 matplotlib 默认字体不含中文。解决办法是显式指定中文字体:

plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False

SimHei是黑体,Windows 自带,Mac 上换成Arial Unicode MS或PingFang SC。unicode_minus那行是防止负号显示成方块。这个设置要在绘图前做,做完之后所有文字标注都正常。如果字体还是找不到,用matplotlib.font_manager查一下系统里有哪些中文字体,别硬猜名字。

提示:字体设置是全局的,如果同一脚本里要画多张风格不同的图,记得在切换时重置rcParams,不然会互相影响。

4.4 保存图片时的白边与裁切

savefig出来的图经常带一圈白边,或者边缘被裁掉。白边用bbox_inches='tight'解决,它会自动裁到内容边界。边缘被裁通常是set_extent的范围和 figure 比例不匹配,加plt.tight_layout()或者手动调subplots_adjust。还有一个坑是dpi设太高导致图片巨大,300 对印刷够用,再高文件体积翻倍但肉眼看不出差别。

这套流程我前后迭代了十几版,从最初画出来一片空白,到现在能稳定出图,中间的经验基本都写在上面的细节里了。最后分享一个我个人的习惯:每次画图前先把数据范围、投影、输出尺寸这三个参数写在脚本开头,当成配置项,改的时候一目了然,比散落在代码各处强得多。地图可视化这事,工具是死的,投影和数据的匹配是活的,把这两者的关系理顺了,剩下的就是调样式的水磨工夫。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询