一、背景与原理
之前我们使用 R 语言的 sf + ggplot2 体系绘制历年中国省市区县地图(小地图版本 + 长版),本讲义将同样的功能用 Python 实现。Python 版本使用 geopandas + matplotlib 体系,提供了完整的模块化绘图函数。
附件中提供了 1949~2021 年每年的省市区县地图数据,包含 mini 和 long 两套:
R 与 Python 的包映射
二、使用 reticulate 创建与管理 Python 虚拟环境
2.1 安装 reticulate
options(repos = c(CRAN = "https://mirrors.tuna.tsinghua.edu.cn/CRAN/"))install.packages("reticulate")2.2 虚拟环境初始化原理
reticulate 会在 R 会话中绑定一个 Python 解释器,绑定后不可更改。因此我们必须在任何 {python} chunk 运行之前,通过 Sys.setenv(RETICULATE_PYTHON = ...) 环境变量锁定 Python 路径。setup chunk 中的代码已完成这一操作。
2.3 安装 Python 包
py_pkgs <- c("numpy", "pandas", "geopandas", "matplotlib","rasterio", "shapely", "openpyxl", "affine")installed <- py_list_packages(".venv")$packageneed_install <- setdiff(py_pkgs, installed)if (length(need_install) > 0) { virtualenv_install(".venv", packages = need_install)cat("已安装:", paste(need_install, collapse = ", "), "\n")2.4 验证激活状态
2.5 查看已安装的包
py_list_packages(".venv")2.6 虚拟环境管理常用命令
virtualenv_remove(".venv")virtualenv_create(".venv")virtualenv_install(".venv", packages = c("numpy", "pandas", "geopandas", ...))三、环境配置与数据准备
3.1 导入 Python 包
import matplotlib.pyplot as pltimport matplotlib.colors as mcolorsimport matplotlib.font_manager as fmfrom matplotlib.patches import Rectanglefrom shapely.geometry import box, Pointfrom shapely.ops import unary_unionwarnings.filterwarnings("ignore")3.2 全局配置
MY_CRS = "+proj=aea +lat_0=0 +lon_0=105 +lat_1=25 +lat_2=47 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs"SMALL_BBOX = {"xmin": 120000, "xmax": 1766004.1,"ymax": 2557786.0, "ymin": 320000}# ---- 字体配置:使用附件中的 LXGWWenKai 字体 ----_font_path = os.path.join(os.getcwd(), "LXGWWenKai-Regular.ttf")fm.fontManager.addfont(_font_path)_font_prop = fm.FontProperties(fname=_font_path)CN_FONT = _font_prop.get_name()plt.rcParams["font.family"] = CN_FONTplt.rcParams["axes.unicode_minus"] = Falseplt.rcParams["font.size"] = 103.3 比例尺与装饰工具函数
defadd_scale_bar(ax, provmap, location="bl", height_scale=0.016, width_scale=0.2): bounds = provmap.total_bounds map_width_m = bounds[2] - bounds[0] target_km = map_width_m * width_scale / 1000 nice_values = [500, 1000, 2000, 3000, 5000, 10000, 20000, 50000, 100000] bar_km = min(nice_values, key=lambda x: abs(x - target_km)) x0_frac = 0.08if location == "bl"else0.62 ax_width = xlim[1] - xlim[0] x0_data = xlim[0] + x0_frac * ax_width y0_data = ylim[0] + 0.04 * (ylim[1] - ylim[0]) bar_height_m = map_width_m * height_scale color = "black"if i % 2 == 0else"white" (x0_data + i * seg_w, y0_data), seg_w, bar_height_m, facecolor=color, edgecolor="black", linewidth=0.5, clip_on=False, zorder=10, ax.text(x0_data + bar_m / 2, y0_data - bar_height_m * 0.3,f"{bar_km:,} km", fontsize=7, ha="center", va="top", color="black", zorder=10) ax.annotate("N", xy=(0.98, 0.95), xycoords="axes fraction", fontsize=10, fontweight="bold", ha="center", va="bottom") ax.annotate("", xy=(0.98, 0.95), xycoords="axes fraction", xytext=(0.98, 0.88), textcoords="axes fraction", arrowprops=dict(arrowstyle="->", color="black", lw=1.5))defdraw_lines(ax, provlinemap):for _, row in provlinemap.iterrows(): color = LINE_COLORS.get(cls, "gray") lw = LINE_WIDTHS.get(cls, 0.3)if geom.geom_type == "MultiLineString": ax.plot(xs, ys, color=color, linewidth=lw, zorder=3)elif geom.geom_type == "LineString": ax.plot(xs, ys, color=color, linewidth=lw, zorder=3)defdraw_province_labels(ax, provmap, centroids):for i, row in provmap.iterrows(): cx, cy = centroids.iloc[i].x, centroids.iloc[i].y ax.text(cx, cy, row["省"], fontsize=5, color="gray", ha="center", va="center", zorder=4)defadd_vertical_colorbar(ax, fig, cmap, norm, label=None, ticks=None, ticklabels=None,"""在地图左下角、比例尺上方绘制竖向分段色条。""" ax_width = xlim[1] - xlim[0] ax_height = ylim[1] - ylim[0] cb_width_m = ax_width * 0.0075 cb_height_m = ax_height * 0.15 seg_height_m = cb_height_m / n_segments x0_data = xlim[0] + x0_frac * ax_width scale_y0 = ylim[0] + 0.04 * ax_height bar_height_m = ax_width * 0.016 cb_bottom = scale_y0 + bar_height_m + ax_height * 0.025 vmin, vmax = norm.vmin, norm.vmaxfor i inrange(n_segments): frac_hi = (i + 1) / n_segments color = cmap((frac_lo + frac_hi) / 2) y_bottom = cb_bottom + i * seg_height_m (x0_data, y_bottom), cb_width_m, seg_height_m, facecolor=color, edgecolor="black", linewidth=0.5, clip_on=False, zorder=10,if ticks isnotNoneand ticklabels isnotNone:for tick_val, tick_lbl inzip(ticks, ticklabels): frac = (tick_val - vmin) / (vmax - vmin) if vmax > vmin else0 frac = max(0, min(1, frac)) y_pos = cb_bottom + frac * cb_height_m [x0_data + cb_width_m, x0_data + cb_width_m + cb_width_m * 0.3], color="black", linewidth=0.5, clip_on=False, zorder=10, x0_data + cb_width_m + cb_width_m * 0.4, y_pos, tick_lbl, fontsize=6, ha="left", va="center", color="black", zorder=10, x0_data + cb_width_m / 2, cb_bottom + cb_height_m + ax_height * 0.01, label, fontsize=7, ha="center", va="bottom", color="black", zorder=10,四、小地图版本
4.1 数据读取
读取小地图版本的中国省级地图数据:
"provmapdata/minishp/chinaprov2019mini/chinaprov2019mini.shp",provmap = provmap.dropna(subset=["省代码"]).reset_index(drop=True)print(f"省级多边形: {len(provmap)} 个区域")读取线条数据(九段线、海岸线、小地图框格):
provlinemap = gpd.read_file("provmapdata/minishp/chinaprov2019mini/chinaprov2019mini_line.shp",keep_classes = ["九段线", "海岸线", "小地图框格"]provlinemap = provlinemap[ provlinemap["class"].isin(keep_classes)][["class", "geometry"]].reset_index(drop=True)print(f"线条: {len(provlinemap)} 条")print(provlinemap["class"].value_counts())4.2 在地图上添加散点图
读取经纬度数据并转换为 GeoDataFrame:
pointdf = pd.read_excel("瞪羚、独角兽、创新型企业经纬度数据.xlsx")pointdf = pointdf.dropna(subset=["经度"]).reset_index(drop=True)print(f"散点数据: {len(pointdf)} 条")# 转换为 GeoDataFrame(WGS84 → MY_CRS)pointdfsf = gpd.GeoDataFrame( geometry=gpd.points_from_xy(pointdf["经度"], pointdf["纬度"]),pointdfsf = pointdfsf.to_crs(MY_CRS)4.3 小地图的散点处理原理
小地图框格显示的是南海诸岛放大图。对于散点数据,需要将落在小地图范围内的点平移到框格位置。处理流程如下:
- 用
st_intersection 提取落在该范围内的散点 - 对这些散点执行:先以范围左下角为原点缩小到 50%,再平移到小地图框格位置
SMALL_BBOX["xmin"], SMALL_BBOX["ymin"], SMALL_BBOX["xmax"], SMALL_BBOX["ymax"]pointdfsf_small = pointdfsf[ pointdfsf.geometry.intersects(small_bbox)print(f"小地图范围内的散点: {len(pointdfsf_small)} 个")# 先将坐标原点移到 (xmin, ymin),再缩放 0.5,最后平移到目标位置gdf_small = pointdfsf_small.copy()gdf_small["geometry"] = gdf_small.geometry.translate( xoff=-SMALL_BBOX["xmin"], yoff=-SMALL_BBOX["ymin"],gdf_small["geometry"] = gdf_small.geometry.scale( xfact=SMALL_SCALE, yfact=SMALL_SCALE, origin=(0, 0),gdf_small["geometry"] = gdf_small.geometry.translate( xoff=SMALL_OFFSET_X + SMALL_BBOX["xmin"] * SMALL_SCALE, yoff=SMALL_OFFSET_Y + SMALL_BBOX["ymin"] * SMALL_SCALE,pointdfsfall = pd.concat([pointdfsf, gdf_small], ignore_index=True)print(f"合并后散点总数: {len(pointdfsfall)} 个")4.4 绘制散点地图
centroids = provmap.geometry.representative_point() plt.colormaps.get_cmap("tab20").colors + plt.colormaps.get_cmap("tab20b").colors + plt.colormaps.get_cmap("Set3").colorsunique_provs = list(dict.fromkeys(pointdfsfall["省"].values))color_map = {prov: all_colors[i % len(all_colors)] for i, prov inenumerate(unique_provs)}五、绘制填充地图
5.1 读取市级地图并统计
虽然绘制市级地图,但我们仍然使用省级线条(突出省份区域):
"citymapdata/minishp/chinacity2021mini/chinacity2021mini.shp",citymap = citymap.dropna(subset=["省代码"]).reset_index(drop=True)print(f"市级多边形: {len(citymap)} 个区域")provlinemap2 = gpd.read_file("provmapdata/minishp/chinaprov2021mini/chinaprov2021mini_line.shp",keep_classes2 = ["九段线", "海岸线", "小地图框格", "省份"]provlinemap2 = provlinemap2[ provlinemap2["class"].isin(keep_classes2)][["class", "geometry"]].reset_index(drop=True)citydf = pointdf.groupby(["市", "市代码"]).size().reset_index(name="n")citymap2 = citymap.merge(citydf, on="市代码", how="left")citymap2["n"] = citymap2["n"].fillna(0)citymap2["plot_val"] = citymap2["n"] + 1# log(0) 无定义print(f"有数据的城市: {(citymap2['n'] > 0).sum()} 个")5.2 连续变量填色地图
fig, ax = plt.subplots(1, 1, figsize=(10, 8.5), dpi=400)cmap = plt.colormaps.get_cmap("YlOrBr")norm = mcolors.LogNorm(vmin=citymap2["plot_val"].min(), vmax=citymap2["plot_val"].max())citymap2.plot(ax=ax, column="plot_val", cmap=cmap, norm=norm, edgecolor="gray", linewidth=0.01, zorder=1)_xl = ax.get_xlim(); _yl = ax.get_ylim()_dw = _xl[1] - _xl[0]; _dh = _yl[1] - _yl[0]fig.set_size_inches(10, 10 * _dh / _dw)ax.set_xlim(_xl); ax.set_ylim(_yl)draw_lines(ax, provlinemap2)for prov, color in color_map.items(): mask = pointdfsfall["省"] == prov pointdfsfall[mask].plot(ax=ax, color=color, markersize=0.5, zorder=2)draw_province_labels(ax, provmap, centroids)5.3 连续变量填色地图(竖直图例)
也可以使用竖直图例展示连续变量:
fig, ax = plt.subplots(1, 1, figsize=(10, 8.5), dpi=400)# 使用与 pic2 相同的 YlOrBr + LogNorm 配色cmap3 = plt.colormaps.get_cmap("YlOrBr")norm3 = mcolors.LogNorm(vmin=citymap2["plot_val"].min(), vmax=citymap2["plot_val"].max())citymap2.plot(ax=ax, column="plot_val", cmap=cmap3, norm=norm3, edgecolor="gray", linewidth=0.01, legend=False, zorder=1)_xl3 = ax.get_xlim(); _yl3 = ax.get_ylim()_dw3 = _xl3[1] - _xl3[0]; _dh3 = _yl3[1] - _yl3[0]fig.set_size_inches(10, 10 * _dh3 / _dw3)ax.set_xlim(_xl3); ax.set_ylim(_yl3)draw_lines(ax, provlinemap2)for prov, color in color_map.items(): mask = pointdfsfall["省"] == prov pointdfsfall[mask].plot(ax=ax, color=color, markersize=0.5, zorder=2)draw_province_labels(ax, provmap, centroids)六、使用栅格数据绘制地图
6.1 高分辨率栅格 → 散点
高分辨率的栅格数据可以先聚合(降采样),再转换成密集的散点数据绘制。相当于 R 中 raster::aggregate() + rasterToPoints() 的组合:
from rasterio.warp import Resamplingfrom shapely.geometry import Point as ShpPointwith rasterio.open(tif_path) as src: new_width = src.width // aggregate_factor new_height = src.height // aggregate_factor out_shape=(src.count, new_height, new_width), resampling=Resampling.average,from affine import Affine src.transform.a * aggregate_factor, src.transform.e * aggregate_factor,print(f"原始尺寸: {src.width} x {src.height}")print(f"聚合后: {new_width} x {new_height}")data_float = data.astype(np.float64) data_float[data == nodata] = np.nanrows, cols = np.where(~np.isnan(data_float) & (data_float > 0))xs = [transform * (c + 0.5, r + 0.5) for r, c inzip(rows, cols)]values = data_float[rows, cols]cn2022points = gpd.GeoDataFrame( geometry=[ShpPoint(x[0], x[1]) for x in xs],cn2022points = cn2022points[cn2022points["cn2022"] > 0].reset_index(drop=True)cn2022points = cn2022points.to_crs(MY_CRS)print(f"有效散点: {len(cn2022points)} 个")print(cn2022points.describe())defsplit_for_small_map(gdf): SMALL_BBOX["xmin"], SMALL_BBOX["ymin"], SMALL_BBOX["xmax"], SMALL_BBOX["ymax"] gdf_small = gdf[gdf.geometry.intersects(small_bbox)].copy() gdf_small["geometry"] = gdf_small.geometry.translate( xoff=-SMALL_BBOX["xmin"], yoff=-SMALL_BBOX["ymin"]) gdf_small["geometry"] = gdf_small.geometry.scale( xfact=SMALL_SCALE, yfact=SMALL_SCALE, origin=(0, 0)) gdf_small["geometry"] = gdf_small.geometry.translate( xoff=SMALL_OFFSET_X + SMALL_BBOX["xmin"] * SMALL_SCALE, yoff=SMALL_OFFSET_Y + SMALL_BBOX["ymin"] * SMALL_SCALE)return pd.concat([gdf, gdf_small], ignore_index=True)cn2022points_all = split_for_small_map(cn2022points)print(f"合并后: {len(cn2022points_all)} 个散点")fig, ax = plt.subplots(1, 1, figsize=(10, 8.5), dpi=400)cmap = plt.colormaps.get_cmap("RdYlBu_r")norm = mcolors.LogNorm(vmin=cn2022points_all["cn2022"].min(), vmax=cn2022points_all["cn2022"].max())cn2022points_all.plot(ax=ax, column="cn2022", cmap=cmap, norm=norm, markersize=0.1, zorder=1)provmap.plot(ax=ax, facecolor="none", edgecolor="gray", linewidth=0.01, zorder=2)_xl4 = ax.get_xlim(); _yl4 = ax.get_ylim()_dw4 = _xl4[1] - _xl4[0]; _dh4 = _yl4[1] - _yl4[0]fig.set_size_inches(10, 10 * _dh4 / _dw4)ax.set_xlim(_xl4); ax.set_ylim(_yl4)draw_lines(ax, provlinemap)for i, row in provmap.iterrows(): cx, cy = centroids.iloc[i].x, centroids.iloc[i].y ax.text(cx, cy, row["省"], fontsize=5, color="gray", ha="center", va="center", zorder=4)ax.set_title("2022 年中国夜间灯光地图", fontsize=14, fontweight="bold", pad=30)ax.text(0.5, 1.03, "绘图:微信公众号 RStata", transform=ax.transAxes, fontsize=9, ha="center", va="bottom",add_scale_bar(ax, provmap)
6.2 低分辨率栅格 → 多边形
低分辨率的栅格数据适合转换成 polygon 绘制:
from rasterio.features import shapes as rasterio_shapesfrom shapely.geometry import shape as shapely_shapetif_path2 = "NH3_em_anthro_2015_sector_ENE.tif"with rasterio.open(tif_path2) as src: transform = src.transformdata_float = data.astype(np.float64) data_float[data == nodata] = np.nanmask = ~np.isnan(data_float) & (data_float > 0) {"properties": {"NH3": v}, "geometry": shapely_shape(s)}for s, v in rasterio_shapes(data_float, transform=transform, mask=mask)cn2022polygons = gpd.GeoDataFrame.from_features(results, crs=src_crs)cn2022polygons = cn2022polygons.to_crs(MY_CRS)print(f"多边形: {len(cn2022polygons)} 个")print(cn2022polygons["NH3"].describe())cn2022polygons_all = split_for_small_map(cn2022polygons)china_union = unary_union(provmap.geometry)cn2022polygons_all = cn2022polygons_all[ cn2022polygons_all.geometry.intersects(china_union)cn2022polygons_all["geometry"] = cn2022polygons_all.geometry.intersection(china_union)values= cn2022polygons_all["NH3"].dropna().valueslog_vals = np.log10(values)rmin, rmax = log_vals.min(), log_vals.max()vmin =10** (rmin +0.02* (rmax - rmin))vmax =10** (rmax -0.02* (rmax - rmin))vmedian =10** np.mean([rmin, rmax])print(f"色标范围: {vmin:.4f} ~ {vmax:.4f}, 中位数: {vmedian:.4f}")fig, ax = plt.subplots(1, 1, figsize=(10, 8.5), dpi=400)cmap = plt.colormaps.get_cmap("YlOrRd_r")norm = mcolors.LogNorm(vmin=vmin, vmax=vmax)cn2022polygons_all.plot(ax=ax, column="NH3", cmap=cmap, norm=norm, edgecolor="none", linewidth=0, zorder=1)_xl5 = ax.get_xlim(); _yl5 = ax.get_ylim()_dw5 = _xl5[1] - _xl5[0]; _dh5 = _yl5[1] - _yl5[0]fig.set_size_inches(10, 10 * _dh5 / _dw5)ax.set_xlim(_xl5); ax.set_ylim(_yl5)provmap.plot(ax=ax, facecolor="none", edgecolor="gray", linewidth=0.01, zorder=2)draw_lines(ax, provlinemap)for i, row in provmap.iterrows(): cx, cy = centroids.iloc[i].x, centroids.iloc[i].y ax.text(cx, cy, row["省"], fontsize=5, color="gray", ha="center", va="center", zorder=4)ax.set_title("各地区 NH₃ 排放分布(单位:kg/m²/yr)", fontsize=14, fontweight="bold", pad=30)ax.text(0.5, 1.03, "绘图:微信公众号 RStata", transform=ax.transAxes, fontsize=9, ha="center", va="bottom",add_scale_bar(ax, provmap)# 水平色条(左下角比例尺上方,宽度为原来的 1/3)
七、长版地图
长版地图数据的使用更简单,这里仅演示散点图。长版不包含小地图框格,没有南海小地图:
provmap_long = gpd.read_file("provmapdata/longshp/chinaprov2019long/chinaprov2019long.shp",provmap_long = provmap_long.dropna(subset=["省代码"]).reset_index(drop=True)print(f"长版省级多边形: {len(provmap_long)} 个")provlinemap_long = gpd.read_file("provmapdata/longshp/chinaprov2019long/chinaprov2019long_line.shp",keep_long = ["九段线", "海岸线"]provlinemap_long = provlinemap_long[ provlinemap_long["class"].isin(keep_long)][["class", "geometry"]].reset_index(drop=True)bounds = provmap_long.total_boundsprint(f"X 范围: {bounds[0]:.0f} ~ {bounds[2]:.0f}")print(f"Y 范围: {bounds[1]:.0f} ~ {bounds[3]:.0f}")fig, ax = plt.subplots(1, 1, figsize=(8, 9), dpi=400)provmap_long.plot(ax=ax, facecolor="white", edgecolor="gray", linewidth=0.01, zorder=1)_xl6 = ax.get_xlim(); _yl6 = ax.get_ylim()_dw6 = _xl6[1] - _xl6[0]; _dh6 = _yl6[1] - _yl6[0]fig.set_size_inches(8, 8 * _dh6 / _dw6)ax.set_xlim(_xl6); ax.set_ylim(_yl6)for prov, color in color_map.items(): mask = pointdfsf["省"] == prov pointdfsf[mask].plot(ax=ax, color=color, markersize=2, zorder=2)draw_lines(ax, provlinemap_long)centroids_long = provmap_long.geometry.representative_point()for i, row in provmap_long.iterrows(): cx, cy = centroids_long.iloc[i].x, centroids_long.iloc[i].y ax.text(cx, cy, row["省"], fontsize=5, color="gray", ha="center", va="center", zorder=4)ax.set_title("瞪羚、独角兽、创新型企业的地理分布", fontsize=14, fontweight="bold", pad=30)ax.text(0.5, 1.03, "绘图:微信公众号 RStata", transform=ax.transAxes, fontsize=9, ha="center", va="bottom",add_scale_bar(ax, provmap_long, height_scale=0.016, width_scale=0.3)fig.text(0.5, 0.01, "数据来源:瞪羚云网站", ha="center", fontsize=7, color="gray")fig.savefig("pic6.png", bbox_inches="tight", dpi=400)
八、模块化绘图函数
上面的代码逐步演示了完整的绘图流程。为了方便复用,所有绘图逻辑已被封装到 draw_china_map.py 模块中,可以直接调用:
from draw_china_map import ( read_provmap, read_provline, read_citymap, read_point_data, plot_mini_scatter_map, plot_mini_choropleth, plot_mini_choropleth_discrete, plot_mini_raster_points, plot_mini_raster_polygons, plot_long_scatter_map,provmap = read_provmap(2019, version="mini")provline = read_provline(2019, version="mini")pointdfsf = read_point_data("瞪羚、独角兽、创新型企业经纬度数据.xlsx")plot_mini_scatter_map(provmap, pointdfsf, provline, save_path="pic1.png")更多关于地图绘制的内容可以学习平台上的系列课程「使用 R 语言进行地理计算」。
如何参加课程?
是不是感觉很硬核!欢迎报名 RStata 培训班获取全部课程和以会员价获取数据资料(10元/份)详情可阅读这篇推文:数据处理、图表绘制、效率分析与计量经济学如何学习~
详情可点击阅读原文进入 RStata 学院了解(从首页的会员卡专区即可查看和购买会员卡)。
更多关于 RStata 培训班的信息可添加微信号 r_stata2 咨询:

附件下载(点击文末的阅读原文即可跳转):
https://rstata.duanshu.com/#/brief/course/72c36d788f9d4497a7e33f23f9437598






