引言
如果遥感工程只能学一个库,那一定是 GDAL。它不只是一个读写库,而是整个地理空间栅格生态的事实标准:rasterio、rioxarray、GeoPandas、QGIS、GRASS 底层都调它,几乎所有卫星影像格式的解析最终都落到它的驱动上。理解 GDAL 的数据模型,就理解了整个栅格处理的抽象方式。
GDAL 的学习曲线陡峭,主要难在三点。第一是抽象层次多:数据集、波段、仿射变换、驱动、子数据集,概念之间关系不直观;第二是 C 风格 API 与 Python 绑定并存,参数命名与默认值容易混淆;第三是性能陷阱隐蔽:一次性读整景会爆内存,逐像元调用会慢上千倍,而两者在代码上看起来差别不大。
本文按「模型、读取、写入、转换、发布」的顺序组织。第 1 到第 4 节讲数据模型与基础读写,第 5 到第 7 节讲重投影、VRT 与 COG 这类进阶操作,第 8 到第 9 节讲性能优化与命令行批量处理。全篇给出可直接运行的 Python 与命令行片段,重点标注那些「看起来对但会出问题」的细节。
目录
- GDAL 数据模型与抽象
- 打开与读取栅格数据集
- 窗口读写与分块处理
- 波段与数据类型转换
- 重投影与 warp
- 虚拟栅格 VRT 与镶嵌
- COG 云优化 GeoTIFF
- Python 绑定与性能优化
- 命令行工具与批量处理
- 权衡取舍
- 常见坑清单
- 小结
1. GDAL 数据模型与抽象
GDAL 的核心抽象是「数据集」(Dataset),一个数据集包含若干「波段」(Band)与一组地理元数据。数据集对应磁盘上的一个文件或一组文件,波段对应单个通道或变量。理解这层抽象,就能解释为什么 NetCDF 的一整个变量集合、Sentinel-2 的一整套波段,在 GDAL 里都表现为「一个数据集加多个波段」。
1.1 数据集、波段与仿射变换
Dataset
├── RasterXSize, RasterYSize 影像宽高(像元数)
├── GeoTransform 6 元组仿射变换(原点、像元尺寸、旋转)
├── Projection 坐标系 WKT 或 EPSG
├── Band 1..N 各波段,各自有数据类型与 NoData
└── Metadata 键值对元数据
仿射变换是定位的核心,它的六个参数依次是:左上角 x 坐标、x 方向像元尺寸、x 方向旋转、左上角 y 坐标、y 方向旋转、y 方向像元尺寸(通常为负,因为影像 y 轴向下)。
1.2 驱动与格式
GDAL 通过「驱动」支持各种格式。同一个扩展名可能对应多个驱动,打开顺序会影响结果。
| 驱动 | 格式 | 特点 |
|---|---|---|
| GTiff | GeoTIFF | 最通用,支持分块与概览 |
| COG | 云优化 GeoTIFF | GTiff 的子集,要求特定布局 |
| JPEG2000 | JP2 | 高压缩,Sentinel-2 原生 |
| HDF5 | HDF5 | 层次结构,子数据集 |
| NetCDF | NetCDF | 多维数组,气候数据 |
| VRT | 虚拟栅格 | XML 描述,不存实际数据 |
| MEM | 内存栅格 | 临时中间结果 |
选择驱动时优先用 GTiff 或 COG,只有在对接特定数据源时才用其原生格式。
2. 打开与读取栅格数据集
打开数据集分只读与可写两种模式。只读模式用 r,可写用 r+ 或 w。打开后必须关闭,否则文件句柄泄漏在批量处理时会迅速耗尽资源。
2.1 只读元数据
import rasterio
with rasterio.open("scene.tif") as src:
print(src.driver) # 驱动名,如 GTiff
print(src.width, src.height) # 宽高(像元数)
print(src.crs) # 坐标系
print(src.transform) # 仿射变换
print(src.bounds) # 地理范围
print(src.count) # 波段数
print(src.dtypes) # 各波段数据类型
print(src.nodata) # NoData 值
print(src.res) # 像元尺寸 (x, y)
2.2 读取整个波段
import rasterio
import numpy as np
with rasterio.open("scene.tif") as src:
band = src.read(1) # 读取第 1 个波段,返回 2D 数组
all_bands = src.read() # 读取全部波段,返回 3D 数组
subset = src.read([1, 2, 3]) # 读取指定波段组合
masked = src.read(1, masked=True) # 自动应用 NoData 掩膜
profile = src.profile # 复制元数据用于写新文件
print(band.shape, band.dtype)
print("有效像元比例:", np.count_nonzero(~np.isnan(masked.filled(np.nan))) / band.size)
2.3 上下文管理器与句柄管理
with 语句保证文件被正确关闭,是必须养成的习惯。一个常见的反模式是在循环里打开而不关闭,几百个文件之后就会遇到「too many open files」。
- 只读打开用
r,不会锁定文件。 - 可写打开用
r+,会修改原文件,慎用。 - 新建用
w,需要提供完整的 profile。 - 内存数据集用
rasterio.MemoryFile(),适合中间结果。
3. 窗口读写与分块处理
处理大影像的核心原则是「不要一次读进内存」。rasterio 提供窗口读取,只读需要的子区域。这是所有大影像处理的基础模式。
3.1 窗口读取
import rasterio
from rasterio.windows import Window
with rasterio.open("big_scene.tif") as src:
win = Window(col_off=1000, row_off=2000, width=512, height=512) # 定义窗口
data = src.read(1, window=win) # 只读该窗口
# 也可以用地理坐标构造窗口
from rasterio.windows import from_bounds
win_geo = from_bounds(116.0, 39.5, 116.5, 40.0, transform=src.transform)
patch = src.read(1, window=win_geo)
3.2 分块迭代
import rasterio
from rasterio.windows import Window
def iter_blocks(path, block=1024):
with rasterio.open(path) as src:
for row in range(0, src.height, block):
for col in range(0, src.width, block):
h = min(block, src.height - row)
w = min(block, src.width - col)
win = Window(col, row, w, h)
yield win, src.read(1, window=win)
total = 0
for win, block_data in iter_blocks("big_scene.tif", 1024):
total += block_data.sum() # 逐块处理,内存恒定
print("总和:", total)
3.3 内存估算
分块大小不是随便选的。一个 1024 × 1024 的 float32 单波段块占 4 MB,如果是 13 个波段则占 52 MB。并行的块数乘以单块内存就是峰值内存。
单块内存 = 块宽 × 块高 × 数据类型字节数 × 波段数
例:1024 × 1024 × 4 字节 × 13 波段 ≈ 52 MB
并行 8 块 → 峰值约 420 MB,加上中间变量要留 2 倍余量
4. 波段与数据类型转换
数据类型转换是隐蔽的错误来源。常见的溢出、精度丢失、NoData 变形都发生在这里。
| 类型 | 字节数 | 范围 | 适用场景 |
|---|---|---|---|
| uint8 | 1 | 0 至 255 | 可视化、掩膜 |
| int16 | 2 | -32768 至 32767 | 有符号整数影像 |
| uint16 | 2 | 0 至 65535 | 反射率缩放后的整数 |
| float32 | 4 | 约 7 位有效数字 | 计算中间结果 |
| float64 | 8 | 约 15 位有效数字 | 高精度统计 |
4.1 类型转换的溢出风险
import numpy as np
a = np.array([60000], dtype=np.uint16)
print(a.astype(np.int16)) # 溢出:60000 变成负数 -5536
print(a.astype(np.float32)) # 安全:转为浮点
b = a.astype(np.float32) / 10000.0 # 正确做法:先转浮点再运算,最后按需转回
4.2 波段重排与写入
import rasterio
import numpy as np
with rasterio.open("in.tif") as src:
profile = src.profile.copy()
profile.update(dtype="float32", count=1, compress="deflate", tiled=True)
data = src.read([4, 3, 2]).astype("float32") # 按 RGB 顺序重排波段
with rasterio.open("out.tif", "w", **profile) as dst:
dst.write(data) # 写入 3 个波段
写入时必须保证 profile 中的 count、dtype、宽高与实际数据一致,否则会报错或写出错误结果。
5. 重投影与 warp
重投影是栅格处理最常见的操作之一,也是最容易出错的。核心是确定目标坐标系、像元尺寸与重采样方法。坐标系的细节可参考 遥感坐标系与投影实战 。
5.1 命令行 warp
gdalwarp -t_srs EPSG:32650 -tr 10 10 -r bilinear -of COG in.tif out.tif # 重投影到 UTM 50N
gdalwarp -t_srs EPSG:4326 -te 116 39.5 117 40.5 in.tif out.tif # 指定输出范围
gdalwarp -tap -tr 10 10 in.tif out.tif # 对齐到像元网格
-tap(target aligned pixels)很关键:它保证输出像元边界与整数网格对齐,多个图层叠加时才不会出现半像元偏移。
5.2 Python warp
import rasterio
from rasterio.warp import calculate_default_transform, reproject, Resampling
dst_crs = "EPSG:32650"
with rasterio.open("in.tif") as src:
transform, width, height = calculate_default_transform(
src.crs, dst_crs, src.width, src.height, *src.bounds) # 计算目标网格
profile = src.profile.copy()
profile.update(crs=dst_crs, transform=transform, width=width, height=height)
with rasterio.open("out.tif", "w", **profile) as dst:
for i in range(1, src.count + 1):
reproject(
source=rasterio.band(src, i),
destination=rasterio.band(dst, i),
src_transform=src.transform, src_crs=src.crs,
dst_transform=transform, dst_crs=dst_crs,
resampling=Resampling.bilinear)
5.3 重采样算法选择
| 算法 | 适用 | 特点 |
|---|---|---|
| nearest | 分类结果、掩膜 | 不产生新值,保持类别 |
| bilinear | 连续影像 | 平滑,轻微模糊 |
| cubic | 连续影像 | 更平滑,可能过冲 |
| average | 下采样 | 平均,适合降分辨率 |
| mode | 分类下采样 | 取众数,保持类别 |
选错算法会引入系统误差:对分类结果用 bilinear 会凭空产生不存在的类别值。
6. 虚拟栅格 VRT 与镶嵌
VRT 是 GDAL 的虚拟格式,用 XML 描述如何从其他文件读取数据,本身不存储像素。它的价值在于把多个文件「拼」成一个逻辑数据集,读取时按需从源文件取数据。
6.1 VRT 结构
<VRTDataset rasterXSize="10980" rasterYSize="10980">
<SRS>EPSG:32650</SRS>
<GeoTransform>300000, 10, 0, 4500000, 0, -10</GeoTransform>
<VRTRasterBand dataType="UInt16" band="1">
<NoDataValue>0</NoDataValue>
<SimpleSource>
<SourceFilename>tile_01.tif</SourceFilename>
<SourceBand>1</SourceBand>
<SrcRect xOff="0" yOff="0" xSize="5490" ySize="5490"/>
<DstRect xOff="0" yOff="0" xSize="5490" ySize="5490"/>
</SimpleSource>
</VRTRasterBand>
</VRTDataset>
6.2 镶嵌与拼接
gdalbuildvrt mosaic.vrt tile_*.tif # 用 VRT 拼接多个瓦片
gdalbuildvrt -resolution average mosaic.vrt tile_*.tif # 统一分辨率后再拼
gdal_translate -of COG mosaic.vrt mosaic.tif # 落成实体 COG
gdal_merge.py -o merged.tif -n 0 tile_*.tif # 直接合并为实体文件
VRT 的优势是零拷贝、即时生效;劣势是源文件被移动后会失效,且读取时需要打开所有源文件句柄。长期存档应转成实体 COG。
7. COG 云优化 GeoTIFF
COG 是当前在线栅格分发的事实标准。它不是新格式,而是对 GeoTIFF 的一组布局要求,使 HTTP Range 请求能高效读取。
7.1 COG 的布局要求
- 内部使用瓦片(tiled),而非条带(striped)。
- 概览(overview)金字塔内嵌在文件末尾。
- 主影像数据块与概览块按行优先排列。
- 支持 DEFLATE 或 LZW 压缩。
7.2 生成与验证
gdal_translate -of COG -co COMPRESS=DEFLATE -co BLOCKSIZE=512 -co OVERVIEWS=IGNORE_EXISTING in.tif out.tif
rio cogeo create in.tif out.tif # rasterio 生态的等价工具
rio cogeo validate out.tif # 验证是否符合 COG 规范
gdalinfo -json out.tif | python -m json.tool # 检查块布局与概览
不满足 COG 要求的文件仍可被读取,但会退化为全量下载,失去云原生的意义。生成后务必用 validate 确认。分发层与瓦片服务的衔接见 遥感瓦片服务与地图发布
。
8. Python 绑定与性能优化
GDAL 有两套 Python 绑定:原生的 osgeo.gdal 与社区的 rasterio。选择哪套是常见的困惑。
8.1 rasterio 与原生的对比
rasterio 基于 numpy 数组,代码更 Pythonic,适合绝大多数应用;原生 gdal 更贴近 C API,功能覆盖更全(如某些高级驱动操作),但代码更冗长。
import rasterio # rasterio 风格:数组导向
with rasterio.open("in.tif") as src:
arr = src.read(1)
from osgeo import gdal # 原生 gdal 风格:句柄导向
ds = gdal.Open("in.tif")
band = ds.GetRasterBand(1)
arr = band.ReadAsArray()
ds = None # 显式释放,触发刷盘
原则是:日常处理用 rasterio,需要用到 rasterio 未封装的高级特性时再回到原生 gdal。
8.2 并行与 Dask
import dask.array as da
import rioxarray
ds = rioxarray.open_rasterio("big_scene.tif", chunks={"x": 2048, "y": 2048}) # 分块懒加载
ndvi = (ds.sel(band=4) - ds.sel(band=3)) / (ds.sel(band=4) + ds.sel(band=3) + 1e-6)
ndvi = ndvi.compute() # 触发并行计算
8.3 常见性能陷阱
- 逐像元 Python 循环:比向量化慢数百倍,一律用 numpy 向量化。
- 反复打开同一文件:每次
open都有开销,循环内应复用句柄。 - 读取不需要的波段:只读需要的波段,I/O 是主要瓶颈。
- 未启用压缩写盘:大文件写盘用 DEFLATE 或 LZW,省空间也省后续 I/O。
- 忽视块大小与缓存:GDAL 有块缓存,设置
GDAL_CACHEMAX能显著提速。
export GDAL_CACHEMAX=512 # 块缓存 512 MB
export GDAL_NUM_THREADS=ALL_CPUS # 多线程编解码
export GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR # 加速云上打开
9. 命令行工具与批量处理
GDAL 的命令行工具是批处理的利器,很多任务一行命令就能完成,不必写代码。
9.1 核心工具清单
| 工具 | 用途 |
|---|---|
| gdalinfo | 查看元数据与统计 |
| gdal_translate | 格式转换、裁剪、重采样 |
| gdalwarp | 重投影、镶嵌、裁剪 |
| gdalbuildvrt | 构建虚拟栅格 |
| gdal_merge.py | 合并多个栅格 |
| gdaldem | 地形分析(坡度、阴影) |
| gdal_calc.py | 波段运算,如计算 NDVI |
| gdaladdo | 构建概览金字塔 |
9.2 批量处理脚本
#!/usr/bin/env bash
set -euo pipefail # 遇错即停
mkdir -p out
for f in raw/*.tif; do
base=$(basename "$f" .tif)
gdalwarp -t_srs EPSG:32650 -tr 10 10 -tap -r bilinear "$f" "out/${base}_utm.tif"
gdal_translate -of COG -co COMPRESS=DEFLATE "out/${base}_utm.tif" "out/${base}_cog.tif"
rm "out/${base}_utm.tif" # 清理中间文件
echo "done: $base"
done
set -euo pipefail 是批量脚本的安全网:任何一步失败都会立即停止,避免错误静默传播到后续文件。生产环境还应记录每个文件的处理日志,便于回溯。
权衡取舍
| 决策点 | 选项 A | 选项 B | 建议 |
|---|---|---|---|
| 绑定选择 | rasterio | 原生 osgeo.gdal | 默认 rasterio,高级特性用原生 |
| 读取策略 | 整景读入 | 窗口分块 | 超过内存 1/4 就分块 |
| 中间格式 | MEM 内存数据集 | 临时文件 | 小数据用 MEM,大数据落盘 |
| 重采样 | 最近邻 | 双线性 | 分类用最近邻,连续用双线性 |
| 存储格式 | 原生 GeoTIFF | COG | 在线分发一律 COG |
| 并行方式 | 多进程 | Dask | 文件级并行用多进程,块级用 Dask |
| 压缩 | 无压缩 | DEFLATE | 存档与传输都开压缩 |
核心判据是「数据量与访问模式决定架构」。小数据怎么简单怎么来,大数据才需要分块、并行与云原生格式。
常见坑清单
- 忘记关闭数据集:批量处理时句柄耗尽报「too many open files」,务必用
with或显式置 None。 - 类型转换溢出:uint16 转 int16 会把大值变负数,运算前先转 float。
- 混淆 x 与 y 方向像元尺寸:仿射变换中 y 方向通常为负,取绝对值会翻转影像。
- 忽略
-tap对齐:重投影后像元未对齐网格,多图层叠加出现半像元偏移。 - 对分类结果用双线性重采样:会凭空产生不存在的类别值,应用最近邻。
- VRT 源文件被移动:VRT 只存路径,源文件一动就失效,存档前落成实体文件。
- COG 未验证:布局不符合规范的文件仍能读,但退化为全量下载。
- 逐像元 Python 循环:性能比向量化慢数百倍,一律用 numpy。
- 未设置 GDAL_CACHEMAX:默认缓存小,大影像反复读盘,设置后提速明显。
- 缩放因子与掩膜顺序颠倒:先换算再掩膜会把 NoData 放大成极端值。
小结
GDAL 的复杂度来自它的抽象层次与历史包袱,但一旦理解了「数据集、波段、仿射变换」这三个核心概念,绝大多数 API 都变得可推断。工程上最重要的三条原则是:永远用窗口或分块处理大影像、永远先掩膜再运算、永远用向量化替代逐像元循环。这三条能规避九成以上的性能与正确性问题。
从工作流角度看,命令行工具负责批量与转换,Python 绑定负责算法与逻辑。二者不是替代关系,而是分工:能用一行 gdalwarp 完成的事就不要写 Python,需要条件逻辑与算法的地方才用代码。把工具链用熟之后,处理一景影像的时间会从「半天调参数」压缩到「几分钟跑完」。
下一步建议把本文的读写模式套用到真实数据上:先读一景 Sentinel-2 的 13 个波段,做窗口裁剪与重投影,算一个植被指数,再转成 COG 并验证。走完这一遍,栅格 I/O 的基本功就扎实了。之后无论是做时序合成、分类还是变化检测,都只是在稳固的 I/O 地基上叠加算法而已。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。