GDAL 与栅格数据读写实战

从工程实战角度讲解 GDAL 与栅格数据读写,覆盖数据模型与抽象、窗口与分块处理、波段与类型转换、重投影 warp、VRT 与镶嵌、COG 云优化、Python 绑定与并行加速,以及命令行工具与批量处理,给出可直接复用的代码片段与性能优化经验。

引言

如果遥感工程只能学一个库,那一定是 GDAL。它不只是一个读写库,而是整个地理空间栅格生态的事实标准:rasterio、rioxarray、GeoPandas、QGIS、GRASS 底层都调它,几乎所有卫星影像格式的解析最终都落到它的驱动上。理解 GDAL 的数据模型,就理解了整个栅格处理的抽象方式。

GDAL 的学习曲线陡峭,主要难在三点。第一是抽象层次多:数据集、波段、仿射变换、驱动、子数据集,概念之间关系不直观;第二是 C 风格 API 与 Python 绑定并存,参数命名与默认值容易混淆;第三是性能陷阱隐蔽:一次性读整景会爆内存,逐像元调用会慢上千倍,而两者在代码上看起来差别不大。

本文按「模型、读取、写入、转换、发布」的顺序组织。第 1 到第 4 节讲数据模型与基础读写,第 5 到第 7 节讲重投影、VRT 与 COG 这类进阶操作,第 8 到第 9 节讲性能优化与命令行批量处理。全篇给出可直接运行的 Python 与命令行片段,重点标注那些「看起来对但会出问题」的细节。

目录

  1. GDAL 数据模型与抽象
  2. 打开与读取栅格数据集
  3. 窗口读写与分块处理
  4. 波段与数据类型转换
  5. 重投影与 warp
  6. 虚拟栅格 VRT 与镶嵌
  7. COG 云优化 GeoTIFF
  8. Python 绑定与性能优化
  9. 命令行工具与批量处理
  10. 权衡取舍
  11. 常见坑清单
  12. 小结

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 通过「驱动」支持各种格式。同一个扩展名可能对应多个驱动,打开顺序会影响结果。

驱动格式特点
GTiffGeoTIFF最通用,支持分块与概览
COG云优化 GeoTIFFGTiff 的子集,要求特定布局
JPEG2000JP2高压缩,Sentinel-2 原生
HDF5HDF5层次结构,子数据集
NetCDFNetCDF多维数组,气候数据
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 变形都发生在这里。

类型字节数范围适用场景
uint810 至 255可视化、掩膜
int162-32768 至 32767有符号整数影像
uint1620 至 65535反射率缩放后的整数
float324约 7 位有效数字计算中间结果
float648约 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,大数据落盘
重采样最近邻双线性分类用最近邻,连续用双线性
存储格式原生 GeoTIFFCOG在线分发一律 COG
并行方式多进程Dask文件级并行用多进程,块级用 Dask
压缩无压缩DEFLATE存档与传输都开压缩

核心判据是「数据量与访问模式决定架构」。小数据怎么简单怎么来,大数据才需要分块、并行与云原生格式。

常见坑清单

  1. 忘记关闭数据集:批量处理时句柄耗尽报「too many open files」,务必用 with 或显式置 None。
  2. 类型转换溢出:uint16 转 int16 会把大值变负数,运算前先转 float。
  3. 混淆 x 与 y 方向像元尺寸:仿射变换中 y 方向通常为负,取绝对值会翻转影像。
  4. 忽略 -tap 对齐:重投影后像元未对齐网格,多图层叠加出现半像元偏移。
  5. 对分类结果用双线性重采样:会凭空产生不存在的类别值,应用最近邻。
  6. VRT 源文件被移动:VRT 只存路径,源文件一动就失效,存档前落成实体文件。
  7. COG 未验证:布局不符合规范的文件仍能读,但退化为全量下载。
  8. 逐像元 Python 循环:性能比向量化慢数百倍,一律用 numpy。
  9. 未设置 GDAL_CACHEMAX:默认缓存小,大影像反复读盘,设置后提速明显。
  10. 缩放因子与掩膜顺序颠倒:先换算再掩膜会把 NoData 放大成极端值。

小结

GDAL 的复杂度来自它的抽象层次与历史包袱,但一旦理解了「数据集、波段、仿射变换」这三个核心概念,绝大多数 API 都变得可推断。工程上最重要的三条原则是:永远用窗口或分块处理大影像、永远先掩膜再运算、永远用向量化替代逐像元循环。这三条能规避九成以上的性能与正确性问题。

从工作流角度看,命令行工具负责批量与转换,Python 绑定负责算法与逻辑。二者不是替代关系,而是分工:能用一行 gdalwarp 完成的事就不要写 Python,需要条件逻辑与算法的地方才用代码。把工具链用熟之后,处理一景影像的时间会从「半天调参数」压缩到「几分钟跑完」。

下一步建议把本文的读写模式套用到真实数据上:先读一景 Sentinel-2 的 13 个波段,做窗口裁剪与重投影,算一个植被指数,再转成 COG 并验证。走完这一遍,栅格 I/O 的基本功就扎实了。之后无论是做时序合成、分类还是变化检测,都只是在稳固的 I/O 地基上叠加算法而已。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「遥感与空间数据」更多文章

  1. 云原生遥感处理
  2. 卫星平台与任务规划
  3. 高光谱遥感处理