1. 为什么我劝你用Python画地图,而不是打开绘图软件
很多人第一次接触地图绘制,脑子里蹦出来的工具是各种在线地图编辑器或者桌面端绘图软件。拖拖拽拽,点几下鼠标,一张图就出来了,看起来门槛极低。但只要你真正做过几个需要反复迭代的项目,就会发现这种“手搓”方式有个致命问题:不可复现。今天调好的配色、标注位置、图层顺序,明天换一台电脑或者换一个同事接手,全部得重来一遍。更别提数据一旦更新,所有手工操作都得推倒重做。
Python做地图绘制,解决的恰恰是这个问题。它把“画地图”这件事从手工劳动变成了代码逻辑。你写的每一行代码,都是对地图样式、数据来源、图层叠加规则的精确描述。这份描述可以版本管理、可以参数化、可以批量跑一百张不同区域的图。对于需要做区域数据分析、业务报表可视化、学术研究配图、物流路径展示的人来说,这套能力是刚需。
这篇文章面向的读者很明确:有基础Python语法知识,但没怎么碰过地理数据可视化的开发者或数据分析师。我会从最核心的库选型讲起,把每个参数背后的逻辑拆开,再给出一套可以直接复制运行的完整代码。源码我会直接嵌在文章里,你不需要去任何地方下载。看完之后,你应该能独立完成从数据准备到出图的全流程,并且知道每一步为什么这么做。
2. 地图绘制的核心工具链选型与底层逻辑
2.1 为什么是Matplotlib加Cartopy这套组合
Python生态里能画地图的库不少,但真正在生产环境里经得起折腾的,主流方案就两套:一套是Matplotlib + Cartopy,另一套是Folium + Leaflet。前者输出静态图,后者输出交互式网页地图。我选前者作为主线,原因有三个。
第一,静态图在报告和论文里的通用性更强。你不可能在PDF报告里嵌一个可交互的网页地图,但你可以嵌一张300dpi的高清PNG。第二,Cartopy对地图投影的支持非常完整,从常见的墨卡托投影到兰勃特等角圆锥投影,再到极地投影,都有现成的实现。第三,Matplotlib的定制能力极强,你想在地图上叠加散点、热力、等值线、比例尺、指北针,都可以用统一的API完成。
Folium当然也有它的场景,比如你需要做一个内部 dashboard 展示门店分布,交互式地图体验更好。但它的定制深度受限于Leaflet的JS生态,遇到复杂的投影变换或者自定义图例,往往要写一堆JavaScript回调,反而更麻烦。所以我的建议是:静态分析图用Cartopy,交互展示用Folium,两者不冲突。
2.2 Cartopy的投影系统到底在做什么
很多人第一次用Cartopy会被“投影”这个概念卡住。我用一个生活化的类比来解释:地球是个橘子,你想把橘子皮完整地摊平在一张桌子上,必然要经过拉伸、撕裂或者压缩。地图投影就是决定“怎么撕、怎么拉”的规则。
Cartopy里最常用的几个投影类,我列个表对比一下:
| 投影名称 | 适用场景 | 特点 |
|---|---|---|
| PlateCarree | 全球概览、经纬度网格 | 最简单,经纬度直接对应XY轴 |
| Mercator | 航海图、Web地图 | 保持角度不变,高纬度面积变形大 |
| LambertConformal | 中纬度区域图(如中国全境) | 保持形状,适合东西跨度大的区域 |
| Orthographic | 半球视图、极地视角 | 看起来像从太空看地球 |
选投影的核心原则是:你的地图要表达什么信息。如果要看全球温度分布,PlateCarree就够了;如果要看某个省份的详细路网,LambertConformal更合适;如果要做一个“从太空看地球”的视觉效果,Orthographic是首选。
2.3 数据格式的坑:Shapefile、GeoJSON和NetCDF
地图数据格式五花八门,新手最容易在这里翻车。我简单梳理一下:
- Shapefile:最传统的地理矢量格式,一套文件包含
.shp、.shx、.dbf等多个文件,缺一不可。优点是兼容性好,缺点是属性字段名有长度限制,中文支持差。 - GeoJSON:基于JSON的矢量格式,单文件,可读性好,适合Web传输和小规模数据。缺点是文件体积大,读取速度慢。
- NetCDF:科学数据常用格式,适合存储网格化数据(如气温、降水),支持多维数组。缺点是结构复杂,需要专门库读取。
我的经验是:矢量边界用Shapefile或GeoJSON,栅格数据用NetCDF或GeoTIFF。如果你从公开渠道下载数据,优先选GeoJSON,因为编码问题少。如果数据量很大,比如全国级别的路网,那就用Shapefile,读取效率更高。
3. 环境搭建与依赖安装的实操细节
3.1 用Conda而不是Pip来管理地理库
地理信息相关的Python库,依赖关系非常复杂。Cartopy底层依赖GEOS、PROJ、GDAL这些C++库,用Pip直接装,十有八九会卡在编译环节。我试过在三个不同系统上用Pip装Cartopy,只有一次成功,还是因为系统里已经预装了GDAL。
所以我的建议很明确:用Conda创建独立环境。命令如下:
conda create -n mapviz python=3.10 conda activate mapviz conda install -c conda-forge cartopy matplotlib numpy pandas shapely geopandas这里有几个细节值得说。第一,-c conda-forge指定从conda-forge频道安装,这个频道的包更新更及时,依赖解析也更合理。第二,geopandas不是必须的,但它处理矢量数据比Cartopy自带的shapereader方便很多,建议一起装上。第三,Python版本选3.10而不是最新的3.12,因为部分地理库对3.12的支持还不完善,3.10是当前最稳的版本。
3.2 验证安装是否成功的三个检查点
装完之后别急着写代码,先做三个检查,能省掉后面很多排查时间。
第一个检查:导入Cartopy并打印版本。
import cartopy print(cartopy.__version__)如果报错说找不到proj或者geos,说明底层C++库没装好,需要重新执行conda install -c conda-forge proj geos。
第二个检查:创建一个最简单的投影对象。
import cartopy.crs as ccrs proj = ccrs.PlateCarree() print(proj)这一步能过,说明投影系统正常。
第三个检查:读取一个内置的边界数据。
import cartopy.feature as cfeature print(cfeature.COASTLINE)如果这一步报网络错误,说明Cartopy在尝试下载自然地球数据。你可以手动下载后放到本地缓存目录,或者直接用cfeature.NaturalEarthFeature指定本地路径。
注意:Cartopy默认会从网络下载地图边界数据,第一次运行时会比较慢。如果你在离线环境工作,需要提前把数据下载好,放到
~/.local/share/cartopy目录下。
4. 从零绘制一张区域地图的完整流程
4.1 数据准备:经纬度数据的清洗与格式化
假设你手里有一份CSV文件,记录了某区域内若干采样点的经纬度和数值。原始数据往往不干净,比如经度写成了字符串、纬度有缺失值、数值列混入了单位符号。在画图之前,必须先把数据整理成规整的二维数组。
我通常用Pandas做这一步:
import pandas as pd import numpy as np df = pd.read_csv('sample_points.csv') df.columns = ['lon', 'lat', 'value'] df['lon'] = pd.to_numeric(df['lon'], errors='coerce') df['lat'] = pd.to_numeric(df['lat'], errors='coerce') df['value'] = pd.to_numeric(df['value'], errors='coerce') df = df.dropna() df = df[(df['lon'] >= 70) & (df['lon'] <= 140)] df = df[(df['lat'] >= 15) & (df['lat'] <= 55)] print(f"有效数据点:{len(df)}")这里有几个关键操作。pd.to_numeric配合errors='coerce'能把无法转换的值变成NaN,然后统一drop掉。经纬度范围过滤是为了排除明显错误的坐标,比如经度写成0或者纬度写成负数。这一步看起来简单,但实际项目中,至少30%的时间花在数据清洗上,地图画不出来,多半是数据有问题。
4.2 创建画布与投影对象
接下来创建画布。我习惯用plt.subplots而不是直接plt.figure,因为前者能更方便地控制子图布局和尺寸。
import matplotlib.pyplot as plt import cartopy.crs as ccrs import cartopy.feature as cfeature fig = plt.figure(figsize=(12, 8), dpi=150) ax = fig.add_subplot(1, 1, 1, projection=ccrs.LambertConformal( central_longitude=105, central_latitude=35, standard_parallels=(25, 47) )) ax.set_extent([75, 135, 18, 54], crs=ccrs.PlateCarree())figsize的单位是英寸,dpi决定输出分辨率。12x8英寸配150dpi,输出图片大约是1800x1200像素,足够报告使用。LambertConformal的三个参数分别控制中央经线、中央纬线和标准纬线。中央经线选105度是因为它大致穿过中国中部,标准纬线选25和47是为了让变形最小的区域覆盖主要陆地。
set_extent的参数顺序是[经度最小值, 经度最大值, 纬度最小值, 纬度最大值],注意这里的crs参数必须指定为PlateCarree,因为extent的数值是按经纬度给的,而画布用的是投影坐标。
4.3 添加地图要素:海岸线、国界、河流
Cartopy内置了几种常用的地理要素,直接调用即可:
ax.add_feature(cfeature.COASTLINE, linewidth=0.8, edgecolor='black') ax.add_feature(cfeature.BORDERS, linewidth=0.6, edgecolor='gray', linestyle='--') ax.add_feature(cfeature.RIVERS, linewidth=0.4, edgecolor='blue', alpha=0.6) ax.add_feature(cfeature.LAKES, facecolor='lightblue', edgecolor='blue', alpha=0.5)这里有个经验:不要一次性把所有要素都加上。要素越多,渲染越慢,而且图面容易显得杂乱。我的习惯是:海岸线和国界必加,河流和湖泊按需添加。如果地图区域很小,比如只画一个城市,那国界和海岸线反而没必要。
另外,cfeature默认使用的是低分辨率数据,适合快速预览。如果要出版级精度,需要用NaturalEarthFeature指定'10m'分辨率:
from cartopy.feature import NaturalEarthFeature states = NaturalEarthFeature(category='cultural', name='admin_0_boundary_lines_land', scale='10m', facecolor='none') ax.add_feature(states, edgecolor='black', linewidth=0.8)提示:10m分辨率的数据文件较大,第一次使用时会自动下载,建议在网络环境良好的情况下提前运行一次。
4.4 绘制散点与热力图:把数据映射到地图上
数据准备好了,地图底图也有了,接下来就是把数据点画上去。散点图是最直接的方式:
sc = ax.scatter(df['lon'], df['lat'], c=df['value'], cmap='RdYlBu_r', s=50, edgecolor='black', linewidth=0.5, transform=ccrs.PlateCarree(), zorder=5) cbar = plt.colorbar(sc, ax=ax, orientation='vertical', shrink=0.7, pad=0.05) cbar.set_label('观测值', fontsize=12)这里的关键参数是transform=ccrs.PlateCarree()。它告诉Cartopy:我给的经纬度是原始经纬度,请自动转换到当前投影的坐标。如果不加这个参数,点会画到错误的位置。zorder=5确保散点图层在底图之上。cmap='RdYlBu_r'是一个从红到蓝的渐变色带,适合表达从高到低的数值变化。
如果数据点很密集,散点图会糊成一团,这时候可以考虑热力图或者等值线填充。热力图的实现稍微复杂一些,需要先做网格化插值:
from scipy.interpolate import griddata lon_grid = np.linspace(75, 135, 200) lat_grid = np.linspace(18, 54, 150) lon_mesh, lat_mesh = np.meshgrid(lon_grid, lat_grid) value_grid = griddata((df['lon'], df['lat']), df['value'], (lon_mesh, lat_mesh), method='cubic') cf = ax.contourf(lon_mesh, lat_mesh, value_grid, levels=15, cmap='RdYlBu_r', transform=ccrs.PlateCarree(), zorder=4)griddata的method参数有三个选项:'nearest'最快但最粗糙,'linear'折中,'cubic'最平滑但计算量大。我一般先用'linear'预览,最终出图时换'cubic'。
4.5 添加比例尺、指北针和网格线
一张专业的地图,比例尺和指北针是加分项。Cartopy本身不直接提供比例尺,但可以用Matplotlib的AnchoredSizeBar来实现:
from matplotlib.offsetbox import AnchoredText scale_bar = AnchoredText('500 km', loc='lower right', pad=0.5, borderpad=0.3, prop=dict(size=10)) ax.add_artist(scale_bar)指北针可以用一个简单的箭头标注:
ax.annotate('N', xy=(0.95, 0.92), xytext=(0.95, 0.85), xycoords='axes fraction', textcoords='axes fraction', arrowprops=dict(facecolor='black', width=2, headwidth=8), ha='center', fontsize=12, fontweight='bold')网格线用gridlines方法:
gl = ax.gridlines(draw_labels=True, linewidth=0.5, color='gray', alpha=0.5, linestyle='--') gl.top_labels = False gl.right_labels = False gl.xlabel_style = {'size': 10} gl.ylabel_style = {'size': 10}draw_labels=True会自动在图的边缘添加经纬度标签,top_labels和right_labels设为False是为了避免标签重复。
5. 常见报错与排查技巧实录
5.1 中文显示乱码的三种解决方案
Matplotlib默认字体不支持中文,地图标题或者图例里出现中文会变成方框。解决方案有三个层次:
第一,全局设置字体:
plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = FalseSimHei是Windows自带黑体,Mac上可以用Arial Unicode MS或者PingFang SC。axes.unicode_minus设为False是为了让负号正常显示。
第二,如果系统没有中文字体,可以手动指定字体文件路径:
from matplotlib.font_manager import FontProperties font = FontProperties(fname='/path/to/your/font.ttf') ax.set_title('区域分布图', fontproperties=font)第三,如果只是个别标签需要中文,用fontproperties参数单独指定即可,不用改全局配置。
5.2 投影转换后坐标偏移的排查思路
有时候你会发现,明明数据点的经纬度是对的,画出来却偏了几百公里。这种情况九成是因为transform参数没设对。检查步骤:
- 确认数据本身的经纬度是WGS84还是其他坐标系。如果是GCJ02或者BD09,需要先做坐标转换。
- 确认
scatter或contourf的transform参数是ccrs.PlateCarree()。 - 确认
set_extent的crs参数也是ccrs.PlateCarree()。
如果这三步都没问题,那可能是投影参数本身的问题。比如LambertConformal的central_longitude设错了,整个地图会旋转。我的经验是:先用PlateCarree画一遍,确认数据位置正确,再换投影。
5.3 渲染速度慢的优化策略
当数据点超过一万个,或者使用了10m精度的边界数据,出图速度会明显变慢。优化手段有几个:
- 降低
dpi:预览时用72,出图时用150或300。 - 简化边界数据:用
shapely的simplify方法对几何体做抽稀。 - 减少
contourf的levels数量:15层和30层的视觉差异不大,但计算量差一倍。 - 关闭不必要的要素:比如河流、湖泊在区域图上可以省略。
我实测下来,一张包含5000个散点、10m国界、15层等值线的区域图,在普通笔记本上大约需要8到12秒。如果超过30秒,基本可以确定是数据精度过高或者投影计算太复杂。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 中文显示为方框 | 字体不支持 | 设置font.sans-serif或指定字体文件 |
| 数据点位置偏移 | transform参数缺失 | 添加transform=ccrs.PlateCarree() |
| 地图边界不显示 | 数据未下载 | 检查网络或手动放置缓存文件 |
| 图片边缘被裁剪 | bbox_inches未设置 | plt.savefig(..., bbox_inches='tight') |
| 颜色条范围不对 | vmin/vmax未指定 | 手动设置vmin和vmax参数 |
| 投影后形状扭曲 | 投影选择不当 | 换用LambertConformal或调整标准纬线 |
6. 完整源码与参数注释
下面是一份可以直接运行的完整代码,我加了详细注释,你只需要把数据文件路径换成自己的即可。
import matplotlib.pyplot as plt import cartopy.crs as ccrs import cartopy.feature as cfeature import pandas as pd import numpy as np from scipy.interpolate import griddata # 设置中文字体 plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False # 读取数据 df = pd.read_csv('sample_points.csv') df.columns = ['lon', 'lat', 'value'] df = df.apply(pd.to_numeric, errors='coerce').dropna() df = df[(df['lon'] >= 75) & (df['lon'] <= 135)] df = df[(df['lat'] >= 18) & (df['lat'] <= 54)] # 创建画布和投影 fig = plt.figure(figsize=(12, 8), dpi=150) ax = fig.add_subplot(1, 1, 1, projection=ccrs.LambertConformal( central_longitude=105, central_latitude=35, standard_parallels=(25, 47) )) ax.set_extent([75, 135, 18, 54], crs=ccrs.PlateCarree()) # 添加地理要素 ax.add_feature(cfeature.COASTLINE, linewidth=0.8, edgecolor='black') ax.add_feature(cfeature.BORDERS, linewidth=0.6, edgecolor='gray', linestyle='--') ax.add_feature(cfeature.RIVERS, linewidth=0.4, edgecolor='blue', alpha=0.5) # 网格化插值 lon_grid = np.linspace(75, 135, 200) lat_grid = np.linspace(18, 54, 150) lon_mesh, lat_mesh = np.meshgrid(lon_grid, lat_grid) value_grid = griddata((df['lon'], df['lat']), df['value'], (lon_mesh, lat_mesh), method='cubic') # 绘制等值线填充 cf = ax.contourf(lon_mesh, lat_mesh, value_grid, levels=15, cmap='RdYlBu_r', transform=ccrs.PlateCarree(), zorder=4) cbar = plt.colorbar(cf, ax=ax, orientation='vertical', shrink=0.7, pad=0.05) cbar.set_label('观测值', fontsize=12) # 叠加散点 ax.scatter(df['lon'], df['lat'], c='black', s=8, alpha=0.6, transform=ccrs.PlateCarree(), zorder=5) # 添加网格线 gl = ax.gridlines(draw_labels=True, linewidth=0.5, color='gray', alpha=0.5, linestyle='--') gl.top_labels = False gl.right_labels = False # 添加指北针 ax.annotate('N', xy=(0.95, 0.92), xytext=(0.95, 0.85), xycoords='axes fraction', textcoords='axes fraction', arrowprops=dict(facecolor='black', width=2, headwidth=8), ha='center', fontsize=12, fontweight='bold') # 保存图片 plt.savefig('map_output.png', bbox_inches='tight', dpi=300) plt.show()这份代码里,griddata的method='cubic'在数据点较少时可能会产生过冲,如果发现等值线出现异常极值,换成'linear'即可。bbox_inches='tight'能自动裁剪空白边缘,避免保存的图片周围有大片留白。
7. 进阶方向:从静态图到动态交互
静态地图能满足大部分报告需求,但如果你需要做数据探索或者内部工具,交互式地图是更好的选择。Folium的入门非常简单:
import folium m = folium.Map(location=[35, 105], zoom_start=4, tiles='OpenStreetMap') for _, row in df.iterrows(): folium.CircleMarker( location=[row['lat'], row['lon']], radius=5, color='red', fill=True, popup=f"值:{row['value']}" ).add_to(m) m.save('interactive_map.html')这段代码会生成一个HTML文件,用浏览器打开就能看到可缩放、可点击的地图。popup参数支持HTML字符串,你可以嵌入表格、图片甚至图表。
另一个进阶方向是批量出图。如果你需要为30个省份各出一张图,手动改参数是不现实的。正确做法是把绘图逻辑封装成函数,用循环遍历省份列表,每次传入不同的set_extent范围和标题。这样半小时就能跑完30张图,而且样式完全统一。
我在实际项目里还遇到过一个需求:把地图和柱状图组合在一起,左边是区域分布,右边是数值排名。这种布局用plt.subplots的gridspec就能实现,核心思路是让地图轴和普通轴共享画布,但各自独立控制。
最后分享一个小技巧:如果你经常需要调整地图样式,可以把常用的配色、线宽、字体大小写成一个配置字典,放在单独的Python文件里。每次画图时导入这个配置,改样式只需要改一个地方,不用在几百行代码里翻找参数。这个习惯能帮你省下大量重复劳动的时间。