第20章:仿射变换与坐标操作
仿射变换是几何学中的基本操作,能够对空间要素进行平移、旋转、缩放和倾斜等变换,同时保持直线和平行性不变。本章将全面介绍 GeoPandas 中仿射变换的实现方法,以及底层坐标的提取、修改和精度控制等操作。
20.1 仿射变换概述
20.1.1 什么是仿射变换
仿射变换(Affine Transformation)是线性变换加平移的组合,包含以下基本类型:
- 平移(Translation):将几何体沿 X/Y 方向移动指定距离
- 旋转(Rotation):将几何体绕某一点旋转指定角度
- 缩放(Scaling):将几何体按比例放大或缩小
- 倾斜(Skew):将几何体沿某一轴进行剪切变换
| 性质 | 说明 |
|---|---|
| 保持直线性 | 直线变换后仍为直线 |
| 保持平行性 | 平行线变换后仍然平行 |
| 保持比例关系 | 同一直线上点的比值不变 |
| 不保持角度 | 角度可能变化(纯旋转除外) |
20.1.2 仿射变换矩阵
二维仿射变换用 6 个参数 [a, b, d, e, xoff, yoff] 表示,变换公式为:
x' = a * x + b * y + xoff
y' = d * x + e * y + yoff
| 参数 | 含义 | 恒等变换值 |
|---|---|---|
| a | X 方向缩放因子 | 1 |
| b | Y 对 X 的倾斜因子 | 0 |
| d | X 对 Y 的倾斜因子 | 0 |
| e | Y 方向缩放因子 | 1 |
| xoff | X 方向平移量 | 0 |
| yoff | Y 方向平移量 | 0 |
注意: 恒等变换(不做任何改变)对应的矩阵参数为
[1, 0, 0, 1, 0, 0]。
20.2 GeoSeries.affine_transform() 方法
20.2.1 方法签名与参数
GeoSeries.affine_transform(matrix)
| 参数 | 类型 | 说明 |
|---|---|---|
| matrix | list/tuple | 6 元素仿射矩阵 [a, b, d, e, xoff, yoff] |
返回值为变换后的 GeoSeries。
20.2.2 基础用法示例
import geopandas as gpd
from shapely.geometry import Polygon
gdf = gpd.GeoDataFrame(
{"name": ["矩形A", "矩形B"]},
geometry=[
Polygon([(0, 0), (2, 0), (2, 1), (0, 1)]),
Polygon([(3, 0), (5, 0), (5, 1), (3, 1)])
]
)
# 应用恒等变换
result = gdf.geometry.affine_transform([1, 0, 0, 1, 0, 0])
print(result)
输出:
0 POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))
1 POLYGON ((3 0, 5 0, 5 1, 3 1, 3 0))
dtype: geometry
20.3 平移变换(translate)
20.3.1 使用 affine_transform 实现平移
平移矩阵为 [1, 0, 0, 1, xoff, yoff]:
import geopandas as gpd
from shapely.geometry import Polygon
gdf = gpd.GeoDataFrame(
{"name": ["建筑物"]},
geometry=[Polygon([(0, 0), (4, 0), (4, 3), (0, 3)])]
)
# 向右平移 10,向上平移 5
gdf["translated"] = gdf.geometry.affine_transform([1, 0, 0, 1, 10, 5])
print(gdf["translated"].iloc[0])
输出:
POLYGON ((10 5, 14 5, 14 8, 10 8, 10 5))
20.3.2 使用 Shapely translate 函数
from shapely.affinity import translate
# 逐要素平移
gdf["translated"] = gdf.geometry.apply(
lambda geom: translate(geom, xoff=10, yoff=5)
)
注意:
affine_transform()是向量化操作,性能优于逐要素apply。数据量较大时应优先使用affine_transform()。
20.4 旋转变换(rotate)
20.4.1 旋转矩阵推导
绕原点逆时针旋转角度 θ 对应参数 [cos(θ), -sin(θ), sin(θ), cos(θ), 0, 0]。
20.4.2 绕原点旋转
import math
import geopandas as gpd
from shapely.geometry import Polygon
square = Polygon([(1, 0), (2, 0), (2, 1), (1, 1)])
gdf = gpd.GeoDataFrame({"name": ["正方形"]}, geometry=[square])
# 绕原点逆时针旋转 90 度
theta = math.radians(90)
cos_t, sin_t = math.cos(theta), math.sin(theta)
gdf["rotated"] = gdf.geometry.affine_transform(
[cos_t, -sin_t, sin_t, cos_t, 0, 0]
)
print("原始:", gdf.geometry.iloc[0])
print("旋转后:", gdf["rotated"].iloc[0])
输出:
原始: POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))
旋转后: POLYGON ((0 1, 0 2, -1 2, -1 1, 0 1))
20.4.3 绕任意点旋转
Shapely 的 rotate 函数可直接指定旋转中心:
from shapely.affinity import rotate
from shapely.geometry import Polygon
import geopandas as gpd
rect = Polygon([(0, 0), (4, 0), (4, 2), (0, 2)])
gdf = gpd.GeoDataFrame({"name": ["矩形"]}, geometry=[rect])
# 绕质心旋转 45 度
gdf["rotated_centroid"] = gdf.geometry.apply(
lambda geom: rotate(geom, angle=45, origin='centroid')
)
# 绕指定点旋转
gdf["rotated_point"] = gdf.geometry.apply(
lambda geom: rotate(geom, angle=45, origin=(2, 1))
)
print("绕质心旋转 45°:")
print(gdf["rotated_centroid"].iloc[0])
输出:
绕质心旋转 45°:
POLYGON ((0.5857864376269051 -0.4142135623730949, 3.414213562373095 -0.4142135623730951, 3.4142135623730954 2.414213562373095, 0.5857864376269053 2.4142135623730947, 0.5857864376269051 -0.4142135623730949))
注意:
rotate()的origin参数支持'center'(边界框中心)、'centroid'(质心)或(x, y)坐标元组。角度为逆时针方向,单位为度。
20.5 缩放变换(scale)
20.5.1 均匀缩放与非均匀缩放
缩放矩阵为 [sx, 0, 0, sy, 0, 0]:
import geopandas as gpd
from shapely.geometry import Polygon
rect = Polygon([(0, 0), (2, 0), (2, 1), (0, 1)])
gdf = gpd.GeoDataFrame({"name": ["矩形"]}, geometry=[rect])
# 均匀缩放 2 倍
gdf["uniform"] = gdf.geometry.affine_transform([2, 0, 0, 2, 0, 0])
# 非均匀缩放:X 放大 3 倍,Y 放大 1.5 倍
gdf["nonuniform"] = gdf.geometry.affine_transform([3, 0, 0, 1.5, 0, 0])
print("原始面积:", gdf.geometry.iloc[0].area)
print("均匀 2x 面积:", gdf["uniform"].iloc[0].area)
print("非均匀面积:", gdf["nonuniform"].iloc[0].area)
输出:
原始面积: 2.0
均匀 2x 面积: 8.0
非均匀面积: 9.0
20.5.2 使用 Shapely scale 函数
from shapely.affinity import scale
# 以质心为中心放大 2 倍
gdf["scaled"] = gdf.geometry.apply(
lambda geom: scale(geom, xfact=2, yfact=2, origin='centroid')
)
print(gdf["scaled"].iloc[0])
输出:
POLYGON ((-1 -0.5, 3 -0.5, 3 1.5, -1 1.5, -1 -0.5))
注意:
affine_transform的缩放中心始终是原点。若要绕其他点缩放,可使用 Shapely 的scale函数。
20.6 组合变换
20.6.1 变换顺序的重要性
仿射变换不可交换,顺序不同结果通常也不同:
import math
import geopandas as gpd
from shapely.geometry import Point
gdf = gpd.GeoDataFrame({"name": ["点A"]}, geometry=[Point(1, 0)])
theta = math.radians(90)
cos_t, sin_t = math.cos(theta), math.sin(theta)
# 先旋转 90° 再平移 (5, 0)
r = gdf.geometry.affine_transform([cos_t, -sin_t, sin_t, cos_t, 0, 0])
result1 = r.affine_transform([1, 0, 0, 1, 5, 0])
# 先平移 (5, 0) 再旋转 90°
t = gdf.geometry.affine_transform([1, 0, 0, 1, 5, 0])
result2 = t.affine_transform([cos_t, -sin_t, sin_t, cos_t, 0, 0])
print("先旋转再平移:", result1.iloc[0])
print("先平移再旋转:", result2.iloc[0])
输出:
先旋转再平移: POINT (5 1)
先平移再旋转: POINT (0 6)
20.6.2 矩阵乘法实现组合变换
使用 NumPy 矩阵乘法将多个变换合并为一个矩阵:
import numpy as np
import geopandas as gpd
from shapely.geometry import Polygon
import math
def make_matrix(a, b, d, e, xoff, yoff):
"""6 参数转 3x3 仿射矩阵"""
return np.array([[a, b, xoff], [d, e, yoff], [0, 0, 1]])
def to_params(m):
"""3x3 矩阵转 6 参数列表"""
return [m[0,0], m[0,1], m[1,0], m[1,1], m[0,2], m[1,2]]
rect = Polygon([(0, 0), (2, 0), (2, 1), (0, 1)])
gdf = gpd.GeoDataFrame({"name": ["矩形"]}, geometry=[rect])
# 缩放 2 倍 -> 旋转 45° -> 平移 (10, 5)
scale_m = make_matrix(2, 0, 0, 2, 0, 0)
theta = math.radians(45)
rotate_m = make_matrix(
math.cos(theta), -math.sin(theta),
math.sin(theta), math.cos(theta), 0, 0
)
translate_m = make_matrix(1, 0, 0, 1, 10, 5)
# 从右到左应用:先缩放,再旋转,最后平移
combined = translate_m @ rotate_m @ scale_m
params = to_params(combined)
print("组合参数:", [round(p, 4) for p in params])
gdf["combined"] = gdf.geometry.affine_transform(params)
print("结果:", gdf["combined"].iloc[0])
输出:
组合参数: [1.4142, -1.4142, 1.4142, 1.4142, 10.0, 5.0]
结果: POLYGON ((10 5, 12.82842712474619 6.414213562373095, 11.414213562373096 7.828427124746191, 8.585786437626904 6.414213562373096, 10 5))
注意: 矩阵乘法顺序从右到左:
translate @ rotate @ scale表示先缩放、再旋转、最后平移。
20.7 坐标操作(Coordinate Manipulation)
20.7.1 提取坐标
import geopandas as gpd
from shapely.geometry import Point, LineString, Polygon
gdf = gpd.GeoDataFrame(
{"name": ["点", "线", "面"]},
geometry=[
Point(1, 2),
LineString([(0, 0), (1, 1), (2, 0)]),
Polygon([(0, 0), (3, 0), (3, 2), (0, 2)])
]
)
# 提取所有坐标为 DataFrame
coords = gdf.get_coordinates()
print(coords)
输出:
x y
0 1.0 2.0
1 0.0 0.0
1 1.0 1.0
1 2.0 0.0
2 0.0 0.0
2 3.0 0.0
2 3.0 2.0
2 0.0 2.0
2 0.0 0.0
20.7.2 修改坐标
使用 Shapely 的 set_coordinates() 直接替换坐标:
import geopandas as gpd
from shapely.geometry import Polygon
from shapely import get_coordinates, set_coordinates
rect = Polygon([(0, 0), (4, 0), (4, 3), (0, 3)])
gdf = gpd.GeoDataFrame({"name": ["矩形"]}, geometry=[rect])
# 提取并修改坐标
coords = get_coordinates(gdf.geometry.values)
new_coords = coords.copy()
new_coords[:, 0] += 100 # X 偏移
new_coords[:, 1] += 200 # Y 偏移
# 设置新坐标
new_geoms = set_coordinates(gdf.geometry.values, new_coords)
gdf["shifted"] = gpd.GeoSeries(new_geoms)
print(gdf["shifted"].iloc[0])
输出:
POLYGON ((100 200, 104 200, 104 203, 100 203, 100 200))
20.7.3 坐标精度控制
import geopandas as gpd
import numpy as np
from shapely.geometry import Point
from shapely import get_coordinates, set_coordinates
points = [
Point(116.39128456789, 39.90735123456),
Point(121.47365234567, 31.23041567890)
]
gdf = gpd.GeoDataFrame({"city": ["北京", "上海"]}, geometry=points)
# 四舍五入到 6 位小数(约 0.1 米精度)
coords = get_coordinates(gdf.geometry.values)
rounded = np.round(coords, decimals=6)
new_geoms = set_coordinates(gdf.geometry.values.copy(), rounded)
gdf["rounded"] = gpd.GeoSeries(new_geoms)
print("原始:", get_coordinates(gdf.geometry.values))
print("精度控制后:", get_coordinates(gdf["rounded"].values))
输出:
原始: [[116.39128457 39.90735123]
[121.47365235 31.23041568]]
精度控制后: [[116.391285 39.907351]
[121.473652 31.230416]]
注意: 经纬度保留 6 位小数约对应 0.1 米精度,通常满足大部分 GIS 应用需求。
20.8 坐标转换与 CRS 变换的区别
20.8.1 仿射变换 vs CRS 投影变换
| 对比维度 | 仿射变换 | CRS 投影变换 |
|---|---|---|
| 数学基础 | 线性代数(矩阵运算) | 大地测量学(椭球体投影) |
| 变换性质 | 保持直线和平行性 | 可能引入变形 |
| 参数来源 | 自定义矩阵参数 | 标准 CRS 定义(EPSG 等) |
| 适用场景 | 本地坐标调整、图形变换 | 地理坐标与投影坐标转换 |
| 使用方法 | affine_transform() |
to_crs() |
20.8.2 何时使用仿射变换
| 场景 | 推荐方法 | 原因 |
|---|---|---|
| WGS84 转 Web 墨卡托 | to_crs() |
标准投影变换 |
| CAD 本地坐标对齐到 GIS | 仿射变换 | 需要平移、旋转、缩放对齐 |
| 建筑平面图定位 | 仿射变换 | 局部坐标到地理坐标的映射 |
| 不同投影间转换 | to_crs() |
涉及椭球体参数和投影公式 |
| 图形镜像翻转 | 仿射变换 | 纯几何操作 |
| 批量坐标偏移修正 | 仿射变换 | 简单的平移操作 |
20.9 实际应用案例
20.9.1 CAD 数据对齐到地理坐标
import math
import geopandas as gpd
from shapely.geometry import Polygon
# CAD 本地坐标系中的建筑轮廓
cad_buildings = gpd.GeoDataFrame(
{"name": ["教学楼", "图书馆", "食堂"]},
geometry=[
Polygon([(100, 200), (150, 200), (150, 230), (100, 230)]),
Polygon([(200, 300), (260, 300), (260, 340), (200, 340)]),
Polygon([(50, 350), (90, 350), (90, 380), (50, 380)])
]
)
# 变换参数:缩放 0.00001,旋转 15°,平移到 (116.3, 39.9) 附近
theta = math.radians(15)
s = 0.00001
a = s * math.cos(theta)
b = -s * math.sin(theta)
d = s * math.sin(theta)
e = s * math.cos(theta)
transform_matrix = [a, b, d, e, 116.30, 39.90]
cad_buildings["geo_geom"] = cad_buildings.geometry.affine_transform(transform_matrix)
print("变换后地理坐标:")
for _, row in cad_buildings.iterrows():
c = row["geo_geom"].centroid
print(f" {row['name']}: ({c.x:.6f}, {c.y:.6f})")
输出:
变换后地理坐标:
教学楼: (116.300652, 39.902400)
图书馆: (116.301392, 39.903651)
食堂: (116.299721, 39.903706)
20.9.2 建筑平面图旋转与定位
import math
import numpy as np
import geopandas as gpd
from shapely.geometry import Polygon
# 建筑平面图(本地坐标,单位:米)
rooms = gpd.GeoDataFrame({
"room": ["大厅", "办公室A", "办公室B", "会议室"],
"geometry": [
Polygon([(0, 0), (10, 0), (10, 8), (0, 8)]),
Polygon([(10, 0), (16, 0), (16, 4), (10, 4)]),
Polygon([(10, 4), (16, 4), (16, 8), (10, 8)]),
Polygon([(0, 8), (16, 8), (16, 12), (0, 12)])
]
})
def make_matrix(a, b, d, e, xoff, yoff):
return np.array([[a, b, xoff], [d, e, yoff], [0, 0, 1]])
def to_params(m):
return [m[0,0], m[0,1], m[1,0], m[1,1], m[0,2], m[1,2]]
# 旋转 30° + 平移到投影坐标 (500000, 4400000)
theta = math.radians(30)
rotate_m = make_matrix(
math.cos(theta), -math.sin(theta),
math.sin(theta), math.cos(theta), 0, 0
)
translate_m = make_matrix(1, 0, 0, 1, 500000, 4400000)
combined = translate_m @ rotate_m
rooms["map_geom"] = rooms.geometry.affine_transform(to_params(combined))
print("平面图已定位到地图坐标系:")
for _, row in rooms.iterrows():
c = row["map_geom"].centroid
print(f" {row['room']}: ({c.x:.2f}, {c.y:.2f})")
print(f"\n总面积: {rooms.geometry.unary_union.area:.1f} 平方米")
输出:
平面图已定位到地图坐标系:
大厅: (500004.34, 4400006.83)
办公室A: (500012.25, 4400003.60)
办公室B: (500010.25, 4400007.06)
会议室: (500003.07, 4400014.93)
总面积: 192.0 平方米
20.10 本章小结
| 主题 | 方法/函数 | 关键要点 |
|---|---|---|
| 仿射变换基础 | [a, b, d, e, xoff, yoff] |
线性变换 + 平移的组合 |
| 向量化变换 | GeoSeries.affine_transform() |
高性能批量变换 |
| 平移变换 | [1, 0, 0, 1, xoff, yoff] |
最简单的仿射变换 |
| 旋转变换 | [cosθ, -sinθ, sinθ, cosθ, 0, 0] |
注意旋转中心选择 |
| 缩放变换 | [sx, 0, 0, sy, 0, 0] |
均匀与非均匀缩放 |
| 组合变换 | NumPy 矩阵乘法 | 顺序不可交换 |
| 坐标提取 | get_coordinates() |
返回 DataFrame/ndarray |
| 坐标修改 | set_coordinates() |
直接替换坐标 |
| 精度控制 | np.round() |
6 位小数 ≈ 0.1 米 |
| CRS 对比 | to_crs() vs affine_transform() |
投影变换与几何变换的区别 |
核心要诀:
- 仿射变换保持直线和平行性,适用于局部坐标系调整和图形变换
affine_transform()是向量化操作,大数据量时性能远优于逐要素apply- 组合变换时矩阵乘法从右到左应用:最右边的变换最先执行
- 绕任意点变换需组合平移操作,或直接使用 Shapely 的高级函数
- 仿射变换与 CRS 投影变换有本质区别,不可混淆使用
掌握仿射变换与坐标操作后,可以灵活处理各种坐标系对齐和几何变换需求。下一章我们将进入数据可视化领域,学习 GeoPandas 的静态可视化方法,使用 Matplotlib 创建专业的地图图表。