
1. 从一张“画歪了”的地图说起地理信息可视化的核心痛点做地理信息可视化的人几乎都经历过这样一个阶段手里有一份带经纬度的数据想把它画到地图上结果要么是点全堆在赤道附近要么是海岸线扭曲得认不出来要么是行政边界和底图对不上。我第一次用 Python 画地图的时候把一份全国城市的经纬度数据直接丢进散点图出来的结果惨不忍睹——所有点挤成一团完全看不出地理分布规律。后来才明白问题出在两个地方一是没有正确的地图投影二是底图和业务数据没有统一坐标系。这个项目标题里的basemapcartopy geopandas 显示地图本质上解决的就是这类问题。它属于地理信息可视化里最基础也最容易被低估的一环把矢量地理数据行政区划、点位、路径和栅格底图海岸线、地形、影像在同一个坐标系下正确叠加渲染。basemap 是早期 matplotlib 生态里画地图的老牌工具库cartopy 则是它的继任者基于 PROJ 和 shapely 构建投影支持更规范geopandas 则是把 pandas 的数据处理能力和 shapely 的几何对象结合起来的空间数据分析库。三者配合能完成从数据读取、坐标转换到最终出图的一整套流程。适合读这篇内容的人大概分三类第一类是刚接触地理数据、想用 Python 快速出图的 analyst手里有 Excel 或 CSV 格式的经纬度表需要做成可交付的地图第二类是做空间分析、需要把统计结果落到地理单元上的研究者比如按行政区聚合指标后做分级设色图第三类是想从 basemap 迁移到 cartopy 的老用户被 basemap 的安装问题和投影报错折磨过想找一条更稳的路。不管你是哪一类下面这些内容都是从实际踩坑里攒出来的不是照着手册念的。2. 整体设计思路为什么是 cartopy 加 geopandas 这套组合2.1 三个库各管一摊职责边界要分清很多人一开始会把 basemap、cartopy、geopandas 混着用结果代码里投影对象满天飞最后自己也搞不清哪个坐标系在起作用。我建议先把三者的职责划清楚这是整套方案能跑通的前提。geopandas 负责“数据”。它读进来的是 GeoDataFrame每一行除了普通属性列还有一个 geometry 列存着 shapely 几何对象点、线、多边形。它的强项是空间数据的组织、筛选、聚合、坐标转换通过to_crs。你可以把它理解成“带地理坐标的 pandas”。cartopy 负责“画布和底图”。它提供GeoAxes也就是一个知道投影规则的坐标轴还内置了海岸线、国界、河流等底图要素的数据接口Natural Earth数据集。它管的是“这张图画在什么投影下、底图画哪些要素”。basemap 是历史选项。它的 API 是Basemap(projection..., llcrnrlon...)这种风格投影参数靠字符串和角点坐标传和现代 matplotlib 的面向对象风格不太合拍而且在新版本 Python 上安装经常出问题。所以除非你维护的是老代码新项目我强烈建议直接用 cartopy。提示basemap 已经停止维护多年官方也推荐迁移到 cartopy。如果你在旧环境里被KeyError: PROJ_LIB之类的报错卡住与其修环境不如直接换 cartopy长期看省事得多。2.2 为什么不用纯 matplotlib 加图片底图有人会问我直接找一张地图图片当背景把点按像素坐标画上去不行吗短期看行长期是灾难。图片底图没有坐标信息一旦你要换投影、缩放范围、或者叠加不同来源的数据像素坐标和经纬度的换算关系就得手工维护误差会累积。而 cartopy 的GeoAxes里你传进去的经纬度会自动按当前投影变换底图要素也跟着投影走数据一致性由库保证。这就是“用地理坐标思考”和“用像素坐标思考”的区别前者可复用后者一次性。2.3 投影选择是整套方案的灵魂地理信息可视化里最容易出错、也最容易被忽视的就是投影。地球是球面严格说是椭球地图是平面把球面摊平必然有变形不同投影保的东西不一样有的保面积等积有的保角度等角/保形有的保距离。选错投影结论可能完全相反。举个实际例子做全国范围的分级设色图如果用 Web 墨卡托EPSG:3857高纬度地区面积会被严重放大北方省份看起来比实际大很多视觉上会误导读者对“面积占比”的判断。这时候更适合用等积投影比如 Albers 等积圆锥投影中国区域常用的是以 105°E 为中央经线的 Albers。而如果是做小范围的街道级点位图用 UTM 分带投影或者直接用经纬度PlateCarree问题都不大。下面这张表是我在实际项目里常用的投影选择参考覆盖了大部分常见场景场景推荐投影理由注意事项全球概览PlateCarree / Robinson经纬度直观 / 视觉均衡Robinson 不保形也不等积仅作展示全国面积统计Albers 等积圆锥保面积适合分级设色中央经线按区域调整中纬度区域Lambert 保形圆锥保角度形状不失真适合气象、航图类小范围工程UTM 分带局部变形小单位是米注意跨带问题Web 地图对齐Web 墨卡托 EPSG:3857与在线底图瓦片一致高纬度面积失真严重选投影的逻辑其实就一句话先问这张图要传达什么信息再决定保什么量。要比较面积就用等积要看形状和方向就用等角要量距离就用等距或 UTM。别默认用经纬度画一切也别盲目跟风用 Web 墨卡托。3. 核心细节解析坐标系、几何对象与底图要素3.1 坐标系CRS必须显式声明不能靠猜geopandas 里每个 GeoDataFrame 都有一个.crs属性记录当前坐标系。这里有个高频坑从 CSV 读经纬度建 GeoDataFrame 时如果不显式指定crsEPSG:4326.crs会是 None后续to_crs转换直接报错或者更隐蔽地——不报错但结果全错。我见过有人把没声明 CRS 的数据直接to_crs(3857)geopandas 会假设输入是 4326 然后转换看起来能跑但如果你的数据其实是别的坐标系结果就悄悄偏了。正确做法是建数据时就声明清楚import geopandas as gpd from shapely.geometry import Point import pandas as pd df pd.read_csv(cities.csv) # 含 lon, lat 两列 geometry [Point(xy) for xy in zip(df[lon], df[lat])] gdf gpd.GeoDataFrame(df, geometrygeometry, crsEPSG:4326)EPSG:4326 就是 WGS84 经纬度绝大多数 GPS 和公开数据都是这个。如果你的数据来自某些在线地图导出的坐标可能是 GCJ-02 或 BD-09 这类经过偏移的坐标系那就需要先做坐标纠偏再声明为 4326否则点位会有几百米的系统性偏移。这个偏移在城市级地图上肉眼可见做精细分析时是致命的。注意.crs为 None 时to_crs的行为在不同 geopandas 版本里不完全一致有的报错有的静默假设。养成“建数据必声明 CRS”的习惯能省掉大量排查时间。3.2 几何对象的类型决定了你能画什么geopandas 的 geometry 列可以是 Point、LineString、Polygon、MultiPolygon 等。画图时不同类型走不同路径点用gdf.plot(marker...)线和面用gdf.plot()默认填充。这里有个细节面数据如果几何无效比如自相交的多边形shapely 在渲染时可能报错或画出诡异形状。处理办法是先做有效性修复from shapely.validation import make_valid gdf[geometry] gdf[geometry].apply(make_valid)另一个常见问题是 MultiPolygon 和 Polygon 混存某些操作比如按面积筛选需要统一。可以用gdf.explode()把 MultiPolygon 拆成单 Polygon或者用gdf.geometry.type先看一眼分布再决定怎么处理。这些细节在官方文档里往往一笔带过但实际数据里几乎必然遇到。3.3 cartopy 底图要素的加载与缓存cartopy 的feature模块提供COASTLINE、BORDERS、RIVERS、LAKES等要素底层是从 Natural Earth 下载对应比例尺的 shapefile。第一次调用会联网下载并缓存到本地默认在用户目录的.local/share/cartopy下。这里有两个实操要点第一比例尺要匹配地图范围。cfeature.COASTLINE.with_scale(110m)是全球概览用的粗粒度10m是精细粒度。如果你画的是全国范围却用了 110m海岸线会粗糙得像手绘反过来画全球却用 10m渲染会慢很多且文件大。一般全国用 50m 或 10m全球用 110m。第二离线环境要提前准备缓存。如果运行环境不能联网第一次调用要素会卡住或报错。解决办法是在能联网的机器上先跑一遍把缓存目录拷过去或者手动下载 Natural Earth 的 shapefile 放到缓存路径。这个坑在部署到内网服务器时特别常见。import cartopy.crs as ccrs import cartopy.feature as cfeature ax plt.axes(projectionccrs.PlateCarree()) ax.add_feature(cfeature.COASTLINE.with_scale(50m), linewidth0.5) ax.add_feature(cfeature.BORDERS.with_scale(50m), linestyle:)3.4 数据图层和底图图层的叠加顺序matplotlib 里图层顺序由zorder控制cartopy 的add_feature和 geopandas 的plot都支持传zorder。默认情况下后画的盖住先画的但底图要素和业务数据混在一起时建议显式指定底图海岸线、国界zorder 设低比如 1业务面数据设 2点位和标注设 3 以上。这样即使代码顺序调整视觉层次也不会乱。我吃过一次亏先画了业务面后加海岸线结果海岸线把面数据的边界盖住了排查了半天才发现是图层顺序问题。4. 实操过程从原始数据到成品地图的完整链路4.1 环境准备与依赖版本这套组合对版本比较敏感尤其是 cartopy 和 PROJ 的兼容性。我实测下来比较稳的组合是 Python 3.10 以上、geopandas 0.14 以上、cartopy 0.22 以上。安装时建议用 conda 而不是纯 pip因为 cartopy 依赖的 GEOS、PROJ、shapely 这些 C 库用 conda 装省心很多conda install -c conda-forge geopandas cartopy matplotlib如果只能用 pipshapely 和 pyproj 现在都有预编译 wheel一般能装上但 cartopy 偶尔会因为找不到 PROJ 数据目录报错。遇到这种情况设置环境变量PROJ_LIB指向 pyproj 自带的 proj 数据目录通常能解决。不过说实话能上 conda 就上 conda省下的时间够你多画好几张图。4.2 读取行政区划数据并统一坐标系假设我们有一份省级行政区划的 shapefile以及一份城市点位 CSV。第一步是把两者都读进来并统一到同一个投影下。这里我选择 Albers 等积投影作为工作坐标系因为后面要做面积相关的分级设色。import geopandas as gpd import cartopy.crs as ccrs import matplotlib.pyplot as plt # 读行政区划 provinces gpd.read_file(provinces.shp) print(provinces.crs) # 先确认原始坐标系 # 定义 Albers 投影以 105E 为中央经线 albers ccrs.AlbersEqualArea( central_longitude105, central_latitude35, standard_parallels(25, 47) ) # 转换到 Albers provinces_albers provinces.to_crs(albers.proj4_init)这里albers.proj4_init拿到的是 PROJ 字符串geopandas 的to_crs能直接吃。标准纬线选 25 和 47 是覆盖中国南北的常用取值中央纬线 35 大致是全国几何中心。这些参数不是随便定的标准纬线选在区域南北边界附近能让中间区域的变形最小。4.3 在 cartopy 画布上叠加 geopandas 图层关键点来了cartopy 的GeoAxes有自己的投影geopandas 的plot需要指定transform告诉它数据是什么坐标系。如果两者不一致图形会错位。正确写法是让 GeoAxes 用同一个 Albers 投影geopandas 传transformccrs.PlateCarree()表示“我的数据是经纬度你帮我转”fig plt.figure(figsize(12, 9)) ax fig.add_subplot(1, 1, 1, projectionalbers) # 底图要素 ax.add_feature(cfeature.COASTLINE.with_scale(50m), linewidth0.4, zorder1) ax.add_feature(cfeature.BORDERS.with_scale(50m), linewidth0.4, zorder1) # 行政区划面 provinces.plot( axax, transformccrs.PlateCarree(), facecolornone, edgecolorgray, linewidth0.6, zorder2 ) # 城市点位 cities.plot( axax, transformccrs.PlateCarree(), colorred, markersize20, zorder3 ) ax.set_extent([73, 135, 18, 54], crsccrs.PlateCarree()) plt.savefig(map.png, dpi200, bbox_inchestight)注意set_extent传的是经纬度范围crsccrs.PlateCarree()告诉 cartopy 这是经纬度它会自动换算到当前 Albers 投影的显示范围。这一步如果漏了crs参数范围会按投影坐标解释图就跑到不知道哪里去了。4.4 分级设色把统计数据落到地理单元上实际项目里更常见的是分级设色图choropleth。假设 provinces 里有一列value存指标值用scheme参数做分级provinces_albers.plot( axax, columnvalue, cmapYlOrRd, schemequantiles, k5, legendTrue, legend_kwds{loc: lower right, title: 指标值}, transformccrs.PlateCarree(), zorder2 )schemequantiles是分位数分级每级样本数相近适合分布偏斜的数据如果数据分布均匀用equal_interval更直观。分级方法的选择会直接影响读者对“哪里高哪里低”的判断这个后面在问题排查里还会展开说。4.5 出图参数与可交付质量出图时几个参数值得调dpi至少 200印刷级要 300bbox_inchestight去掉多余白边figsize按目标用途定A4 横版大概 12x9 英寸。另外中文标注需要设置字体否则会显示成方块plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False字体这块在服务器环境经常缺中文字体要么装字体要么出图时用英文标注。我一般建议交付前先在目标环境试渲染一次别等最后才发现中文全是豆腐块。5. 常见问题与排查技巧实录5.1 地图空白或只显示一小块最常见的原因是set_extent的坐标系没对上。如果 GeoAxes 是投影坐标系而set_extent传了经纬度却没加crsccrs.PlateCarree()cartopy 会把 73 到 135 当成投影坐标的米数范围小得几乎看不见。排查方法先注释掉set_extent看默认范围是否正常再逐步加回。另一个可能是数据本身坐标就是投影坐标但transform传了 PlateCarree导致数据被二次变换到错误位置。5.2 点位整体偏移几百米这是坐标系偏移的典型症状。如果你的经纬度来自某些在线地图的拾取工具很可能是 GCJ-02 坐标系直接当 WGS84 用会有系统性偏移。解决办法是做坐标纠偏转换或者换用官方发布的 WGS84 数据源。判断方法找一个已知地标的真实坐标对比偏移量在几百米量级且方向一致基本就是这个问题。5.3 面数据渲染出现空洞或自相交几何无效导致的。用make_valid修复后再画或者用gdf.is_valid先筛出无效几何单独处理。自相交多边形在填充渲染时会出现奇怪的镂空肉眼一看就不对劲但如果不熟悉几何有效性这个概念很容易以为是数据缺失。5.4 底图要素下载卡住离线环境或网络受限时的典型表现。cartopy 的要素是懒加载的第一次用才下载。解决办法是提前在有网环境跑一遍把缓存目录cartopy.config[data_dir]整个拷到目标机器或者手动放置 shapefile。缓存目录路径可以通过cartopy.config查看和修改。5.5 分级设色图例和实际颜色对不上这个坑很隐蔽。geopandas 的scheme分级和legendTrue生成的图例在某些版本里如果k值和实际分级数不一致图例标签会错位。排查时先打印gdf[value].describe()看分布再确认分级后的边界值。另外cmap选离散色带如YlOrRd配scheme比连续色带更清晰连续色带在分级图里会让读者误以为是连续渐变。下面这张速查表把上面几个问题归了类方便对照现象最可能原因快速验证解决方向地图空白set_extent 坐标系错注释 set_extent 看默认范围补 crs 参数点位偏移坐标系偏移GCJ-02 等对比已知地标坐标坐标纠偏面有空洞几何无效/自相交gdf.is_valid 检查make_valid 修复底图卡住要素未缓存且无网络看 cartopy 缓存目录预置缓存图例错位分级数与图例不一致打印分级边界值统一 k 与分级5.6 一个容易被忽略的性能问题当面数据顶点特别多比如高精度行政区划时渲染会明显变慢。可以在不影响视觉精度的前提下做几何简化provinces_simplified provinces_albers.geometry.simplify(1000) # 单位是米1000 米的简化容差在全国尺度上肉眼几乎看不出差别但顶点数能降一个数量级出图速度提升明显。这个技巧在批量出图时特别有用我做过一次 30 多个省份的批量渲染简化后总耗时从几分钟降到几十秒。6. 投影与分级的组合陷阱两个真实场景的取舍6.1 面积对比场景等积投影不是可选项而是必选项前面提过 Web 墨卡托在高纬度放大面积的问题这里展开说一个具体场景。假设你要做一张“各省森林覆盖率”的分级设色图读者会下意识地通过色块面积来感知“森林多的省份是不是面积也大”。如果用墨卡托北方省份视觉面积被放大读者可能高估其森林总量。换成 Albers 等积后色块面积和实际面积成比例视觉判断才可靠。这个取舍不是审美问题是信息准确性问题。判断方法很简单问自己“读者会不会从图上比较面积”。会就必须等积不会只是看点位分布那投影选择可以宽松些。这个判断标准比记一堆投影名称实用得多。6.2 分级方法选择分位数、等间距还是自然断点分级设色的分级方法直接影响结论呈现。分位数quantiles每级样本数相同适合突出“相对高低”但可能把差异很小的值分到不同级等间距equal_interval按数值范围均分适合分布均匀的数据偏斜数据会导致大部分样本挤在一两级自然断点fisher_jenks找数据本身的聚集边界视觉上最“顺眼”但计算稍慢且分级边界不直观。我的经验是先看数据分布再选方法。用直方图或describe()看一眼偏斜严重就用分位数接近均匀就用等间距需要讲故事、强调聚集特征就用自然断点。别默认用某一种换一种分级可能让同一份数据讲出不同的故事这在汇报场景里是要命的。6.3 图例设计的一个细节分级设色图的图例如果只标颜色不标数值边界读者无法准确读数。geopandas 的legend_kwds支持传labels但更稳的做法是手动构造图例把每级的数值区间标清楚。另外颜色数量别超过 7 级人眼分辨能力有限超过 7 级相邻颜色就难区分了。这个细节在正式交付物里很影响专业度。7. 从能跑到好用几个提升交付质量的经验7.1 把投影参数抽成配置项目里如果多张图用同一套投影把 Albers 的参数抽成字典或配置项避免每张图重复写且写错。我习惯定义一个get_projection()函数返回 cartopy 投影对象所有图共用。这样改中央经线或标准纬线时只改一处不会出现几张图投影不一致的尴尬。7.2 出图前做一次“数据体检”正式渲染前跑一遍检查CRS 是否声明、几何是否有效、数值列是否有空值、范围是否合理。这几项用几行代码就能查完但能挡掉八成以上的渲染异常。我现在的习惯是写一个check_gdf()小函数每次读数据后先调一下比出图后对着空白画布猜原因高效得多。7.3 保存中间结果便于复现坐标转换后的 GeoDataFrame 可以存成 GeoPackage 或 Parquetgeopandas 支持下次直接读省掉重复转换。尤其是底图要素的缓存和行政区划的投影转换存一次能省不少时间。GeoPackage 的好处是单文件、跨平台、保留 CRS 信息比 shapefile 那套多文件结构清爽。7.4 关于 basemap 迁移的一点实话如果你手里有 basemap 的老代码迁移到 cartopy 的主要工作量在投影参数和底图要素的写法上。basemap 的drawcoastlines()对应 cartopy 的add_feature(cfeature.COASTLINE)drawcountries()对应BORDERS投影对象从字符串参数变成ccrs里的类实例。逻辑不复杂但逐行改比较枯燥。我的建议是新建一个 cartopy 版本的绘图函数老代码留着对照别在原文件上硬改改到一半容易乱。7.5 一个关于范围裁剪的小技巧有时候底图要素画出来超出了你设定的显示范围边缘会有多余线条。除了set_extent还可以用ax.set_boundary()配合一个裁剪路径做更精确的裁剪或者用clip_onTrue让要素自动裁剪到坐标轴范围内。后者最简单大部分场景够用。如果要做不规则边界裁剪比如只显示某个流域内的地图就需要构造 shapely 多边形作为裁剪路径这个属于进阶用法按需使用。8. 写在最后的一点个人体会这套 cartopy 加 geopandas 的组合我用了几年下来最大的感受是难点从来不在 API 本身而在对坐标系和投影的理解。API 查文档半小时能会但投影选错、CRS 没声明这类问题往往要等到图出来发现不对才回头排查而那时候已经浪费了不少时间。所以我现在带新人第一件事不是教怎么调plot参数而是让他们先把 EPSG:4326 和几种常见投影的区别搞清楚知道什么场景该保面积、什么场景该保形状。另一个体会是地理可视化里“看起来对”和“实际对”经常是两回事。一张图渲染出来没有报错、形状也像那么回事不代表坐标没错、投影没错、分级没错。养成出图前做数据体检、出图后对照已知地标核验的习惯比追求花哨的样式重要得多。毕竟地图的第一职责是准确传达空间信息好看是第二位的。