znlgis 博客

GIS开发与技术分享

第16章:空间连接(Spatial Join)

空间连接是地理空间分析中最常用的操作之一。它根据两组地理要素之间的空间关系(如相交、包含、邻近等),将一个 GeoDataFrame 的属性信息关联到另一个 GeoDataFrame 上。本章将全面介绍 GeoPandas 中 sjoin()sjoin_nearest() 的使用方法、参数配置与性能优化策略。


16.1 空间连接概述

16.1.1 什么是空间连接

空间连接(Spatial Join)是指根据两个数据集中要素之间的空间关系,将一方的属性附加到另一方的过程。与 pandas 中基于列值的 merge() 类似,空间连接基于 几何对象的空间关系 进行匹配。

例如:

  • 将每个 POI(兴趣点)关联到它所在的行政区
  • 将建筑物关联到其所在的洪水风险区域
  • 将交通事故关联到最近的道路段

16.1.2 空间连接与属性连接的区别

特征 属性连接(merge) 空间连接(sjoin)
连接依据 列值匹配 空间关系匹配
输入数据 DataFrame / GeoDataFrame 两个 GeoDataFrame
匹配方式 精确值匹配或键值匹配 几何关系判定
典型场景 合并统计表 点落入多边形分析
性能影响 取决于数据量 取决于几何复杂度与空间索引

16.1.3 空间连接的工作流程

空间连接通常经过以下步骤:

  1. 空间索引过滤:利用 R-tree 快速筛选可能匹配的候选要素对
  2. 精确谓词判定:对候选对执行精确的空间关系判断(如 intersectswithin
  3. 属性合并:将匹配要素的属性按指定方式合并到结果中
import geopandas as gpd
from shapely.geometry import Point, Polygon

# 创建点数据 - POI
pois = gpd.GeoDataFrame({
    'poi_name': ['商场A', '学校B', '医院C', '公园D', '餐厅E'],
    'category': ['商业', '教育', '医疗', '休闲', '餐饮'],
    'geometry': [
        Point(116.40, 39.92), Point(116.42, 39.94),
        Point(116.38, 39.90), Point(116.44, 39.96),
        Point(116.41, 39.93)
    ]
}, crs="EPSG:4326")

# 创建面数据 - 行政区
districts = gpd.GeoDataFrame({
    'district': ['朝阳区', '海淀区'],
    'geometry': [
        Polygon([(116.39, 39.91), (116.45, 39.91),
                 (116.45, 39.97), (116.39, 39.97)]),
        Polygon([(116.36, 39.88), (116.41, 39.88),
                 (116.41, 39.93), (116.36, 39.93)])
    ]
}, crs="EPSG:4326")

print("POI 数据:")
print(pois)
print("\n行政区数据:")
print(districts)

16.2 sjoin() 基础用法

16.2.1 基本语法

sjoin() 是 GeoPandas 提供的空间连接函数,基本语法如下:

geopandas.sjoin(left_df, right_df, how='inner', predicate='intersects')

也可以使用 GeoDataFrame 的方法形式:

left_df.sjoin(right_df, how='inner', predicate='intersects')

16.2.2 最简单的空间连接

# 将 POI 与行政区进行空间连接
result = gpd.sjoin(pois, districts, how='inner', predicate='intersects')
print(result)

输出结果将包含每个 POI 的原始属性,加上其所在行政区的属性。

16.2.3 how 参数 - 连接方式

how 参数控制连接方式,与 SQL 的 JOIN 类型类似:

how 值 说明 保留的行
'inner' 内连接(默认) 只保留两边都有匹配的行
'left' 左连接 保留左表所有行,右表无匹配则为 NaN
'right' 右连接 保留右表所有行,左表无匹配则为 NaN
# 内连接 - 只保留有匹配的 POI
inner_result = gpd.sjoin(pois, districts, how='inner')
print(f"内连接结果数: {len(inner_result)}")

# 左连接 - 保留所有 POI,没有落在任何区的 POI 属性为 NaN
left_result = gpd.sjoin(pois, districts, how='left')
print(f"左连接结果数: {len(left_result)}")

# 右连接 - 保留所有行政区
right_result = gpd.sjoin(pois, districts, how='right')
print(f"右连接结果数: {len(right_result)}")

16.2.4 predicate 参数 - 空间谓词

predicate 参数指定空间关系的判定方式:

# 使用 intersects(默认)- 相交
result_intersects = gpd.sjoin(pois, districts, predicate='intersects')

# 使用 within - 在...之内
result_within = gpd.sjoin(pois, districts, predicate='within')

# 使用 contains - 包含
result_contains = gpd.sjoin(districts, pois, predicate='contains')

16.3 sjoin() 参数详解

16.3.1 lsuffix 和 rsuffix

当两个 GeoDataFrame 存在同名列时,lsuffixrsuffix 用于区分来源:

# 两个数据集都有 'name' 列
gdf1 = gpd.GeoDataFrame({
    'name': ['点A', '点B'],
    'value': [10, 20],
    'geometry': [Point(0, 0), Point(1, 1)]
})

gdf2 = gpd.GeoDataFrame({
    'name': ['区域1'],
    'area_km2': [100],
    'geometry': [Polygon([(-.5, -.5), (1.5, -.5), (1.5, 1.5), (-.5, 1.5)])]
})

# 默认后缀为 'left' 和 'right'
result = gpd.sjoin(gdf1, gdf2, how='inner')
print(result.columns.tolist())
# 输出: ['name_left', 'value', 'geometry', 'index_right', 'name_right', 'area_km2']

# 自定义后缀
result = gpd.sjoin(gdf1, gdf2, how='inner', lsuffix='poi', rsuffix='zone')
print(result.columns.tolist())

16.3.2 结果的索引

空间连接的结果保留左表的索引,并额外添加一个 index_right 列(或 index_left 列),记录匹配到的右表(或左表)行的原始索引:

result = gpd.sjoin(pois, districts)
print(result.index)        # 保留 pois 的索引
print(result['index_right'])  # 记录匹配到的 districts 索引

16.3.3 一对多匹配

当一个要素与多个要素匹配时,结果中会出现重复的索引:

from shapely.geometry import Point, Polygon

# 一个点同时落在两个重叠的多边形中
points = gpd.GeoDataFrame({
    'name': ['中心点'],
    'geometry': [Point(0.5, 0.5)]
})

polygons = gpd.GeoDataFrame({
    'zone': ['区域A', '区域B'],
    'geometry': [
        Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]),
        Polygon([(0.3, 0.3), (1.3, 0.3), (1.3, 1.3), (0.3, 1.3)])
    ]
})

result = gpd.sjoin(points, polygons)
print(f"输入点数: {len(points)}, 输出行数: {len(result)}")
print(result)
# 中心点出现两次,分别匹配区域A和区域B

16.4 常用空间谓词

16.4.1 谓词概览

谓词 说明 典型场景
intersects 几何对象有任何交集 通用连接(默认)
within 左表几何完全在右表几何内部 点在多边形内
contains 左表几何完全包含右表几何 多边形包含点
crosses 几何对象交叉(部分重叠) 道路穿越区域
touches 几何对象仅边界接触 相邻区域判定
overlaps 几何对象部分重叠 区域重叠分析
covers 左表几何覆盖右表几何 覆盖关系分析
covered_by 左表几何被右表几何覆盖 被覆盖分析

16.4.2 intersects - 相交

intersects 是最宽泛的谓词,只要两个几何对象有任何共享的空间部分(包括边界接触),就认为匹配:

import geopandas as gpd
from shapely.geometry import Point, LineString, Polygon

# 多边形
poly = gpd.GeoDataFrame({
    'name': ['区域'],
    'geometry': [Polygon([(0, 0), (2, 0), (2, 2), (0, 2)])]
})

# 测试不同几何与多边形的 intersects 关系
tests = gpd.GeoDataFrame({
    'desc': ['内部点', '边界点', '外部点', '穿越线', '外部线'],
    'geometry': [
        Point(1, 1),           # 在内部
        Point(2, 2),           # 在边界上
        Point(3, 3),           # 在外部
        LineString([(1, -1), (1, 3)]),  # 穿越
        LineString([(3, 0), (4, 0)])    # 在外部
    ]
})

result = gpd.sjoin(tests, poly, predicate='intersects')
print("intersects 匹配的要素:")
print(result['desc'].tolist())
# 输出: ['内部点', '边界点', '穿越线']

16.4.3 within 与 contains

withincontains 是互逆关系:

# within: 左表在右表内
result_within = gpd.sjoin(pois, districts, predicate='within')
print("within 结果:", len(result_within))

# contains: 左表包含右表(注意左右表顺序颠倒)
result_contains = gpd.sjoin(districts, pois, predicate='contains')
print("contains 结果:", len(result_contains))

# 两者结果本质相同,只是视角不同

16.4.4 crosses 与 touches

from shapely.geometry import LineString, Polygon

# 道路数据
roads = gpd.GeoDataFrame({
    'road': ['主干道', '环路', '支路'],
    'geometry': [
        LineString([(0, 1), (3, 1)]),   # 穿越区域
        LineString([(0, 0), (2, 0)]),   # 接触边界
        LineString([(3, 0), (4, 1)])    # 完全在外部
    ]
})

# 区域数据
zones = gpd.GeoDataFrame({
    'zone': ['核心区'],
    'geometry': [Polygon([(0.5, 0), (2.5, 0), (2.5, 2), (0.5, 2)])]
})

# crosses - 穿越(几何有交集但不完全包含)
result_crosses = gpd.sjoin(roads, zones, predicate='crosses')
print("穿越核心区的道路:", result_crosses['road'].tolist())

# touches - 仅边界接触
result_touches = gpd.sjoin(roads, zones, predicate='touches')
print("与核心区边界接触的道路:", result_touches['road'].tolist())

16.4.5 选择合适的谓词

分析需求 推荐谓词 说明
点落入哪个区域 within 最精确,排除边界上的点
哪些要素与区域有关 intersects 最宽泛,包含所有相关要素
区域包含哪些要素 contains 注意左右表顺序
道路穿过哪些区域 crosses 仅匹配穿越的情况
寻找相邻区域 touches 仅边界相邻

16.5 sjoin_nearest() 最近邻连接

16.5.1 基本概念

sjoin_nearest() 将左表的每个要素与右表中 距离最近 的要素进行连接,即使它们没有空间交集。这在处理不完全重叠的数据时非常有用。

geopandas.sjoin_nearest(left_df, right_df, how='inner',
                        max_distance=None, distance_col=None)

16.5.2 基础用法

import geopandas as gpd
from shapely.geometry import Point

# 事故发生地点
accidents = gpd.GeoDataFrame({
    'accident_id': [1, 2, 3],
    'severity': ['严重', '轻微', '一般'],
    'geometry': [Point(116.40, 39.91), Point(116.43, 39.95), Point(116.37, 39.89)]
}, crs="EPSG:4326")

# 医院位置
hospitals = gpd.GeoDataFrame({
    'hospital': ['协和医院', '人民医院', '中日友好医院'],
    'beds': [2000, 1500, 1800],
    'geometry': [Point(116.41, 39.92), Point(116.38, 39.90), Point(116.44, 39.96)]
}, crs="EPSG:4326")

# 为每个事故找到最近的医院
result = gpd.sjoin_nearest(accidents, hospitals)
print(result[['accident_id', 'severity', 'hospital', 'beds']])

16.5.3 max_distance 参数

max_distance 限制最近邻搜索的最大距离。超过此距离的匹配将被排除:

# 投影到平面坐标系以使用米为单位
accidents_proj = accidents.to_crs(epsg=32650)
hospitals_proj = hospitals.to_crs(epsg=32650)

# 只查找 3000 米范围内的最近医院
result = gpd.sjoin_nearest(
    accidents_proj, hospitals_proj,
    max_distance=3000  # 单位: 米(取决于 CRS)
)
print(f"在 3000 米内找到匹配的事故数: {len(result)}")

注意max_distance 的单位取决于 GeoDataFrame 的 CRS。使用地理坐标系(如 EPSG:4326)时单位为度,使用投影坐标系时单位为米。

16.5.4 distance_col 参数

distance_col 指定一个列名,用于在结果中存储实际距离值:

# 计算并保存距离
result = gpd.sjoin_nearest(
    accidents_proj, hospitals_proj,
    distance_col='dist_to_hospital'
)
print(result[['accident_id', 'hospital', 'dist_to_hospital']])

输出示例:

accident_id hospital dist_to_hospital
1 协和医院 1432.5
2 中日友好医院 1587.3
3 人民医院 1245.8

16.5.5 处理等距情况

当左表的一个要素与右表的多个要素距离相同时,所有等距的要素都会被返回:

# 一个点与两个点等距
center = gpd.GeoDataFrame({
    'name': ['中心'],
    'geometry': [Point(0, 0)]
})

targets = gpd.GeoDataFrame({
    'target': ['东', '西'],
    'geometry': [Point(1, 0), Point(-1, 0)]
})

result = gpd.sjoin_nearest(center, targets, distance_col='dist')
print(result)
# 可能返回两行,因为东和西距离相等

16.6 性能优化

16.6.1 空间索引的自动使用

sjoin() 在执行时会自动利用右表的空间索引进行加速:

import geopandas as gpd
from shapely.geometry import Point, Polygon
import numpy as np
import time

np.random.seed(42)

# 创建大规模数据
n_points = 100000
points = gpd.GeoDataFrame({
    'id': range(n_points),
    'geometry': [Point(x, y) for x, y in zip(
        np.random.uniform(0, 100, n_points),
        np.random.uniform(0, 100, n_points)
    )]
})

# 创建网格多边形
polygons = []
names = []
for i in range(10):
    for j in range(10):
        polygons.append(Polygon([
            (i*10, j*10), ((i+1)*10, j*10),
            ((i+1)*10, (j+1)*10), (i*10, (j+1)*10)
        ]))
        names.append(f"网格_{i}_{j}")

grid = gpd.GeoDataFrame({'name': names, 'geometry': polygons})

# 空间连接自动利用空间索引
start = time.time()
result = gpd.sjoin(points, grid, predicate='within')
elapsed = time.time() - start
print(f"空间连接 {n_points} 个点与 {len(grid)} 个网格: {elapsed:.3f} 秒")
print(f"匹配结果数: {len(result)}")

16.6.2 坐标参考系统的一致性

空间连接要求两个 GeoDataFrame 使用相同的 CRS,否则会引发警告或错误:

# 确保 CRS 一致
if pois.crs != districts.crs:
    districts = districts.to_crs(pois.crs)

result = gpd.sjoin(pois, districts)

16.6.3 数据预处理优化

# 1. 先用边界框粗筛,减少参与连接的数据量
bbox = districts.total_bounds  # [minx, miny, maxx, maxy]
pois_filtered = pois.cx[bbox[0]:bbox[2], bbox[1]:bbox[3]]

# 2. 投影到合适的平面坐标系
pois_proj = pois_filtered.to_crs(epsg=32650)
districts_proj = districts.to_crs(epsg=32650)

# 3. 执行空间连接
result = gpd.sjoin(pois_proj, districts_proj, predicate='within')

16.6.4 分块处理大数据集

import pandas as pd

def sjoin_chunked(left, right, chunk_size=10000, **kwargs):
    """分块执行空间连接,降低内存峰值"""
    results = []
    n_chunks = (len(left) + chunk_size - 1) // chunk_size

    for i in range(n_chunks):
        start_idx = i * chunk_size
        end_idx = min((i + 1) * chunk_size, len(left))
        chunk = left.iloc[start_idx:end_idx]
        chunk_result = gpd.sjoin(chunk, right, **kwargs)
        results.append(chunk_result)

    return pd.concat(results, ignore_index=False)

# 使用分块处理
result = sjoin_chunked(points, grid, chunk_size=20000, predicate='within')
print(f"分块连接结果数: {len(result)}")

16.6.5 性能对比

优化策略 适用场景 效果
自动空间索引 所有场景 默认启用,显著加速
CRS 一致性 多源数据 避免错误结果
边界框预筛选 数据分布不均匀 减少计算量
分块处理 内存不足 降低内存峰值
投影坐标系 需要距离计算 提高精度

16.7 实际应用案例

16.7.1 案例一:点在多边形分析 - 统计各区 POI 数量

import geopandas as gpd
from shapely.geometry import Point, Polygon
import numpy as np

np.random.seed(42)

# 模拟 POI 数据
n_pois = 5000
poi_data = gpd.GeoDataFrame({
    'poi_type': np.random.choice(['餐饮', '商业', '教育', '医疗'], n_pois),
    'geometry': [Point(x, y) for x, y in zip(
        np.random.uniform(116.2, 116.6, n_pois),
        np.random.uniform(39.8, 40.1, n_pois)
    )]
}, crs="EPSG:4326")

# 模拟行政区数据
districts_data = gpd.GeoDataFrame({
    'district': ['A区', 'B区', 'C区', 'D区'],
    'geometry': [
        Polygon([(116.2, 39.8), (116.4, 39.8), (116.4, 39.95), (116.2, 39.95)]),
        Polygon([(116.4, 39.8), (116.6, 39.8), (116.6, 39.95), (116.4, 39.95)]),
        Polygon([(116.2, 39.95), (116.4, 39.95), (116.4, 40.1), (116.2, 40.1)]),
        Polygon([(116.4, 39.95), (116.6, 39.95), (116.6, 40.1), (116.4, 40.1)])
    ]
}, crs="EPSG:4326")

# 空间连接
joined = gpd.sjoin(poi_data, districts_data, predicate='within')

# 按区和 POI 类型统计数量
poi_stats = joined.groupby(['district', 'poi_type']).size().unstack(fill_value=0)
print("各区 POI 数量统计:")
print(poi_stats)

# 计算各区 POI 总数
poi_total = joined.groupby('district').size()
print("\n各区 POI 总数:")
print(poi_total)

16.7.2 案例二:最近设施分析

import geopandas as gpd
from shapely.geometry import Point
import numpy as np

np.random.seed(42)

# 居民点
n_residents = 1000
residents = gpd.GeoDataFrame({
    'resident_id': range(n_residents),
    'geometry': [Point(x, y) for x, y in zip(
        np.random.uniform(0, 10000, n_residents),
        np.random.uniform(0, 10000, n_residents)
    )]
}, crs="EPSG:32650")

# 消防站
stations = gpd.GeoDataFrame({
    'station': ['消防站A', '消防站B', '消防站C', '消防站D', '消防站E'],
    'geometry': [
        Point(2000, 2000), Point(8000, 2000),
        Point(5000, 5000), Point(2000, 8000), Point(8000, 8000)
    ]
}, crs="EPSG:32650")

# 最近邻连接:找到每个居民点最近的消防站及距离
result = gpd.sjoin_nearest(
    residents, stations,
    distance_col='distance_m'
)

# 统计各消防站的服务人数
service_stats = result.groupby('station').agg(
    服务人数=('resident_id', 'count'),
    平均距离=('distance_m', 'mean'),
    最大距离=('distance_m', 'max')
).round(1)

print("消防站服务统计:")
print(service_stats)

# 标记超过 3000 米的居民点为"服务薄弱区"
result['risk'] = result['distance_m'] > 3000
print(f"\n距离最近消防站超过 3000 米的居民点: {result['risk'].sum()} 个")
print(f"占比: {result['risk'].mean():.1%}")

16.7.3 案例三:将属性从面传递到线

import geopandas as gpd
from shapely.geometry import LineString, Polygon

# 道路数据
roads = gpd.GeoDataFrame({
    'road_name': ['长安街', '三环路', '四环路段'],
    'length_km': [12.5, 48.3, 65.2],
    'geometry': [
        LineString([(116.30, 39.91), (116.50, 39.91)]),
        LineString([(116.32, 39.88), (116.48, 39.88), (116.48, 39.98)]),
        LineString([(116.28, 39.85), (116.52, 39.85)])
    ]
}, crs="EPSG:4326")

# 行政区
admin_zones = gpd.GeoDataFrame({
    'zone': ['城区', '郊区'],
    'zone_type': ['核心区', '发展区'],
    'geometry': [
        Polygon([(116.30, 39.87), (116.50, 39.87),
                 (116.50, 39.95), (116.30, 39.95)]),
        Polygon([(116.25, 39.83), (116.55, 39.83),
                 (116.55, 39.87), (116.25, 39.87)])
    ]
}, crs="EPSG:4326")

# 空间连接 - 找出道路穿过的区域
road_zones = gpd.sjoin(roads, admin_zones, predicate='intersects')
print("道路与区域的关联:")
print(road_zones[['road_name', 'zone', 'zone_type']])

16.8 本章小结

本章全面介绍了 GeoPandas 中的空间连接操作。主要内容回顾:

核心函数

函数 说明
gpd.sjoin() 基于空间谓词的空间连接
gpd.sjoin_nearest() 基于最近距离的空间连接

关键参数

参数 说明 默认值
how 连接方式(inner/left/right) 'inner'
predicate 空间谓词(intersects/within/contains 等) 'intersects'
lsuffix/rsuffix 同名列的后缀 'left'/'right'
max_distance 最近邻搜索最大距离 None
distance_col 存储距离值的列名 None

使用建议

  1. 选择合适的谓词:根据分析需求选择最精确的空间谓词,避免不必要的匹配
  2. 确保 CRS 一致:连接前务必检查并统一坐标参考系统
  3. 使用投影坐标系:涉及距离计算时,先投影到合适的平面坐标系
  4. 注意一对多:一个要素可能匹配多个要素,导致结果行数大于输入行数
  5. 利用空间索引sjoin() 自动使用空间索引,无需手动创建

在下一章中,我们将学习几何叠加分析(Overlay),它是空间连接的延伸,不仅关联属性,还对几何对象本身进行交集、并集等操作。