☰
Python GDAL读写TIF栅格数据:从仿射变换到窗口压缩实战
2026/10/15 16:18:20 网站建设 项目流程

做遥感也好,做GIS二次开发也好,只要跟栅格数据打交道,绕不开的一关就是用Python读写TIF文件。GDAL这个库说它是事实标准一点都不夸张,几乎所有开源GIS软件底层都在用它处理栅格数据。但真正上手的时候,很多人会发现读个小tif好像挺简单,真到自己写数据、处理坐标系、处理大文件的时候,各种问题就冒出来了。

这篇就把我这些年用Python GDAL读写TIF栅格数据踩过的坑、总结出的套路一次说清楚。不光是给你贴一段能跑的代码,更要把为什么要这么写、有哪些细节容易翻车讲明白,方便你直接在自己的项目里用起来。

1. 整体思路与方案选型

1.1 为什么读TIF最好用GDAL

Rasterio、OpenCV、PIL这些库也能读写TIF,但论综合能力,GDAL是最稳的选择。原因有几个层面:

第一是格式支持极其全面。普通TIF、GeoTIFF、BigTIFF、压缩过的TIF、多波段多尺度的COG,GDAL都能读。而且它不只支持TIF,像IMG、ENVI、NetCDF这些遥感领域常碰到的格式它也一并支持。项目后期如果数据源变了,代码改动最小。

第二是它把空间参考信息处理得很完整。一张地理TIF文件里不只有像素值,还有坐标原点、像元大小、仿射变换参数、投影信息。GDAL把读取和写入这些信息的接口做得非常完备,虽然API风格有点老派,但确实可靠。

第三是处理大文件的能力。GDAL可以只读取一个窗口(window)范围内的数据,不用整幅影像全部载入内存。本地实测一张2GB的TIF,在普通电脑上按窗口读取完全不会卡死。这一点是很多传统图像库做不到的。

1.2 Python绑定的选择:osgeo.gdal与Rasterio的取舍

用Python调GDAL主要有两条路,一是直接用osgeo.gdal模块,二是用基于GDAL封装起来的Rasterio。

我的习惯是如果只是处理纯栅格数据、不涉及太多矢量和投影转换,Rasterio的API更符合Python直觉,代码写起来干净。但如果要做批量处理、自定义像素级别的算法,或者需要在GDAL的DataSet层面做精细控制,我会直接用osgeo.gdal,毕竟它才是底层本体,参数透明,遇到问题也好排查。

下面说的内容以osgeo.gdal为主,必要时补充Rasterio的对应写法。其实两条路的底层逻辑是完全相通的,理解了GDAL的对象模型,用Rasterio只是换了一层皮。

2. 环境准备与安装避坑

2.1 各平台安装方式对比

安装GDAL这步看着简单,实际出错率很高,尤其是很多同学在Windows上装,可能遇到找不到DLL、pip安装后import报错等问题。

Linux环境下最简单,直接用conda装一般很省心:

conda install -c conda-forge gdal python-gdal

这样装会把GDAL库本身和Python绑定一起配好,不需要手动管路径。macOS也可以用conda-forge这条路线,比用homebrew单独装再配Python包要省事得多。

Windows环境我强烈建议也走conda,而不是直接pip install GDAL。后者虽然能装,但总是出现版本不匹配或者运行时找不到proj库的情况。我用conda-forge在Windows上试过多个版本,基本没有踩过环境相关的坑。

Python 3.9+用户在安装一些老版本的GDAL绑定包时,可能会被告知没有对应的wheel包。这种情况不用死磕,直接装新版本就行。GDAL API在读写基础功能上非常稳定,跨版本迁移的影响很小。

2.2 验证安装是否成功

装完后最重要的一步是验证绑定是否正常,注意这句:

from osgeo import gdal print(gdal.VersionInfo())

如果这行能正常输出版本号,说明GDAL核心库和Python绑定之间的连接没问题。很多环境问题在这一步就暴露了,比如DLL缺失、路径不对、Python位数不匹配(32位Python配64位GDAL会报错)等。

补充一句,请留意gdal.UseExceptions()这行代码。GDAL的Python默认在出错时不抛异常,而是返回None或者一个错误码,大多数新手会在这上面吃闷亏。习惯性在导入模块后调用gdal.UseExceptions(),后续程序出错时就能直接看到异常信息,调试体验会明显改善。

3. 读取TIF:完整拆解与实操要点

3.1 打开文件与获取基本信息

读一张TIF的第一步是打开文件,这一步要理解GDAL里Dataset和数据波段的关系。可以用一个比喻:Dataset就好比整本书,波段则是书里的每一页,大部分遥感TIF有多页内容,例如RGB三个波段或高光谱几十个波段。

from osgeo import gdal ds = gdal.Open(r"D:\data\image.tif") if ds is None: print("打开失败,检查路径或文件是否损坏") return cols = ds.RasterXSize rows = ds.RasterYSize bands = ds.RasterCount print(f"列数(宽度):{cols},行数(高度):{rows},波段数:{bands}") # 读取仿射变换六参数 geotransform = ds.GetGeoTransform() print(geotransform) # 输出示例:(x原点坐标, 像元宽度, 旋转项, y原点坐标, 旋转项, 像元高度) # 读取投影信息(WKT字符串) projection = ds.GetProjection()

很多人拿到geotransform的六个值会发懵:为什么y方向的像元高度是负数?这其实是栅格数据存储方式决定的。GDAL约定原点坐标在左上角,x向右增长,y向下增长。因此在大多数情况下,六参数中的第6项是负数,比如常见的0.5米分辨率影像,第6项就是-0.5。这个方向问题在你后续要叠加矢量数据或做像素坐标和地理坐标互转时非常关键。

经验提醒:下载的影像如果不带坐标信息(普通TIF),那么ds.GetGeoTransform()返回的是默认的(0, 1, 0, 0, 0, -1)或类似值,ds.GetProjection()为空字符串。这时候不要怀疑代码,是你的影像本身没有地理参考。

3.2 读取栅格数组的几种姿势

读取像素数据时,我见过有人一上来就直接整幅read,导致内存爆掉的例子并不少。实际上GDAL给了一整套按需读取的手段,这里分几种情况来讲。

第一种,单波段整幅读取:

band = ds.GetRasterBand(1) data = band.ReadAsArray() print(data.shape, data.dtype)

注意:dtype很关键。部分TIF是无符号8位整型,部分是有符号16位整型,还有32位浮点型。如果你直接读出来了再强行转类型,在数据值域跨度大的时候会失真。正确做法是先用band.DataType确认原始类型,再决定怎么处理。

第二种,多波段同时读取:

# 方法1:循环读 data_list = [] for i in range(1, ds.RasterCount + 1): band = ds.GetRasterBand(i) data_list.append(band.ReadAsArray()) multiband = np.stack(data_list, axis=-1) # 转为(height, width, bands) # 方法2:直接读所有波段(GDAL 3.x可用) data_all = ds.ReadAsArray()

多波段stack时尤其要留意波段顺序。GDAL读出来的波段顺序就是文件内的存储顺序,TIF很多时候是BGR顺序存的,但显示时需要用RGB顺序。如果不做Index_Order调整,颜色显示就会跟预期不一样。这类影像色彩异常的问题比较容易诊断。

第三种,只读取影像的某个子区域(窗口读取)。当你只需要研究某个ROI区域,或者影像超大没法一次性装进内存时:

# 从(500, 600)开始,读1024x768大小的区域 data = band.ReadAsArray(500, 600, 1024, 768)

这里特别注意参数顺序:前两个参数xoff和yoff是起始像素坐标(相对于左上角),第三个参数是读取的列数xsize,第四个是行数ysize。很多人会记混x和y,导致读出来的数据形状跟预期不符。

如果要按地理坐标范围来读窗口,需要先把地理坐标转换成像素坐标。转换公式是:

geo = ds.GetGeoTransform() # 地理坐标转像素坐标 px = int((x_geo - geo[0]) / geo[1]) py = int((y_geo - geo[3]) / geo[5])

3.3 读取栅格的统计信息与NoData值

有经验的开发者会特别关注NoData值和有效值范围。遥感数据经常存在无效区,例如数据拼接产生的黑边、云遮挡区域等,这些区域的像素值通常会设置为某个特殊值,比如0、-9999或者255。

band = ds.GetRasterBand(1) nodata = band.GetNoDataValue() print("NoData值:", nodata) # 获取统计信息:min, max, mean, std stats = band.GetStatistics(True, True) print("统计信息:", stats)

如果NoData值没被正确设置,后续不管是做统计、拉伸、镶嵌,还是计算NDVI之类的指数,结果都可能被无效像元污染。我们在做数据清洗时,第一步就要把NoData值查找出来,然后设置成一个统一的值来方便统一处理。

4. 写入TIF:从零创建一个标准GeoTIFF

4.1 创建新文件的完整流程

写TIF文件比读要稍微复杂一些,但也不是什么难事。只要把GDAL对象模型和参数理解清楚了,流程很固定。

基础流程五步走:

  1. 用driver创建目标文件
  2. 指定行列数和波段数
  3. 定义数据类型
  4. 写入仿射变换参数和投影
  5. 写入像素数据并关闭文件

下面是一段可以直接复制的模板:

from osgeo import gdal import numpy as np def write_tif(path, array, geotransform, projection, nodata=None): """ 将numpy数组写成GeoTIFF文件 array: (bands, rows, cols) 或 (rows, cols) """ # 统一输入格式为三维 if array.ndim == 2: array = array[np.newaxis, :, :] bands, rows, cols = array.shape[:3] # 根据数组dtype推断GDAL数据类型 dtype_dict = { 'uint8': gdal.GDT_Byte, 'int16': gdal.GDT_Int16, 'uint16': gdal.GDT_UInt16, 'int32': gdal.GDT_Int32, 'float32': gdal.GDT_Float32, 'float64': gdal.GDT_Float64, } gdal_dtype = dtype_dict.get(array.dtype.name) if gdal_dtype is None: print(f"不支持的数据类型:{array.dtype}") return # 创建驱动 driver = gdal.GetDriverByName("GTiff") driver.Register() # 创建数据集 ds = driver.Create(path, cols, rows, bands, gdal_dtype) if ds is None: raise IOError(f"无法创建TIF文件: {path}") # 写入仿射变换和投影 ds.SetGeoTransform(geotransform) ds.SetProjection(projection) # 写入波段数据 for i in range(1, bands + 1): band = ds.GetRasterBand(i) band.WriteArray(array[i - 1]) if nodata is not None: band.SetNoDataValue(nodata) # 释放资源,把缓存写入磁盘 ds.FlushCache() ds = None

这五个步骤缺一不可。特别提一下ds.FlushCache()这一步:它确保所有被GDAL缓冲的数据真正落盘。有时候你感觉数据写完了,程序也正常退出,但生成的文件大小是0或者打开是空的,多半是忘记刷缓存就关闭了。虽然ds = None之后GDAL会自动清理缓存,但养成主动FlushCache的习惯能避免很多隐性问题。

4.2 仿射变换参数和投影从哪里来

很多人写文件时最大的困惑是:geotransform和projection参数到底怎么填?

如果是新生成的数据,最理想的做法是从原始数据里复制。这个场景很常见:你读取了一张原始TIF,做了一些像素级别的运算,比如计算NDVI、归一化等,最后想把结果输出成一张标准TIF,这时候直接取原始影像的geotransform和projection就行。

# 从源影像中拷贝坐标和投影 src_ds = gdal.Open(source_path) geo = src_ds.GetGeoTransform() proj = src_ds.GetProjection() # 假设result是运算结果,形状和原始影像一致 write_tif("ndvi_result.tif", result, geo, proj, nodata=-9999)

如果不是从已有影像复制,而是从零构建自己的网格,那么需要手动算出仿射变换参数。核心公式就一句话:左上角坐标+像元边长。比如你想创建一张覆盖某个区域、像元大小为10米的影像,那么:

origin_x = 500000.0 # 左上角X坐标 origin_y = 4000000.0 # 左上角Y坐标 pixel_size = 10.0 geotransform = (origin_x, pixel_size, 0, origin_y, 0, -pixel_size)

注意第5项和第6项:如果你处理的是一个完全水平的影像、没有旋转,那么第三和第五项都是0。第六项是负像元大小,这一点新手常忘记。

4.3 多波段写入与波段顺序问题

写入三波段RGB影像时,有不少人会问要不要先把数据转成BGR。答案取决于你的数组内容和显示需求。如果数据是来自GDAL读取的,那波段顺序就是原始文件顺序。如果你从OpenCV读图得到的是BGR顺序的数组,直接写到TIF里,多数GIS软件打开时显示的通道顺序可能是反的。我这里提供一个通用的处理原则:

  • 如果你写TIF的目的是遥感分析(比如训练模型、计算指数),保持波段原始顺序,不要乱转。
  • 如果你写TIF的目的是做可视化底图,请先确认你的是RGB还是BGR,再按需求转换。

4.4 使用GeoTIFF压缩选项来节省磁盘空间

很多人忽略了TIF也可以像JPEG那样设置压缩参数,但它属于无损压缩范畴。传统TIF文件动辄几百MB甚至几GB,在磁盘上占空间不说,传输也慢。

GDAL创建GeoTIFF时可以指定压缩算法,常用的是LZW或者DEFLATE。

options = ["COMPRESS=LZW", "TILED=YES", "PREDICTOR=2"] driver = gdal.GetDriverByName("GTiff") ds = driver.Create(path, cols, rows, bands, gdal_dtype, options=options)

几个参数的含义:

COMPRESS=LZW:无损压缩方式,对NRG与DEM这类连续数据压缩效果特别好,原始影像一般能压到原来的30%-50%。

TILED=YES:是否使用分块存储格式。分块以后,读取局部区域的效率会大幅提升。因为传统的TIF是行扫描保存,如果你想读取图像中间的一小块区域,很难做到高效IO,分块后读取是先定位块再解压,速度明显快。

PREDICTOR:预测器选项主要配合浮点数据使用,值为2表示水平差分预测,对像元值变化平缓的栅格数据效果很好。如果你处理的是高程或反射率这类连续数据,开这个选项能进一步提高压缩比。

有一点要提醒:压缩选项会影响运行速度,写入和读取都需要额外的CPU开销。对于要频繁读写的小型TIF,开不开压缩都无所谓;但对大文件,建议开,因为省下的磁盘IO时间往往比CPU解压时间更可观。

5. 常用操作:裁剪、重投影与格网转TIF

5.1 按矢量边界裁剪栅格

这个需求在项目里出现频率很高:手头有一张大范围的遥感影像,想按某个矢量边界把研究区域裁出来。GDAL的Warp接口是一条捷径。

from osgeo import gdal, ogr def clip_raster_by_vector(input_tif, output_tif, shapefile_path): gdal.Warp(output_tif, input_tif, cutlineDSName=shapefile_path, cropToCutline=True, dstNodata=-9999, options=["COMPRESS=LZW"])

gdal.Warp的底层逻辑是:先把矢量裁剪线叠加到栅格的坐标系中,然后做重采样拼接。需要关注的是cutlineDSName参数:传入的是矢量文件路径。cropToCutline=True表示输出范围会严格限定在矢量边界内,最终得到的不再是一个矩形区域,而是依照边界形状的近似矩形。

如果矢量数据的坐标系与栅格不一致,gdal.Warp会自动做重投影,不需要你额外处理,这是它最省心的地方。

5.2 影像重投影:把WGS84转成UTM

另一种高频操作用到的是影像重投影,场景是:数据源是等经纬度,但做面积计算需要用到投影坐标系。

def reproject(input_tif, output_tif, target_epsg): gdal.Warp(output_tif, input_tif, dstSRS=f"EPSG:{target_epsg}", resampleAlg="bilinear", options=["COMPRESS=LZW"])

resampleAlg参数要结合用途选:如果是地物分类结果或土地利用类型,用最邻近法nearest,避免改变类别值;如果是NDVI或高程这类连续量,用双线性bilinear或三次卷积cubic,结果会更平滑。

实际使用中注意重投影以后,输出影像的像元大小和行列数都会变化,这是正常的。因为投影变换会引入几何形变,GDAL会在目标坐标系中重建一个全新的网格。如果对输出分辨率有明确要求,加上xRes、yRes参数来指定。

5.3 Numpy数组直接转TIF的内存优化策略

如果数组是超大规模,例如测试一张50000x50000的浮点数组,要直接一趟写完是不现实的,内存直接爆炸。正确的策略是分块gdal写入。

def write_large_tif(path, cols, rows, block_size=512): driver = gdal.GetDriverByName("GTiff") ds = driver.Create(path, cols, rows, 1, gdal.GDT_Float32, options=["TILED=YES", "COMPRESS=LZW"]) band = ds.GetRasterBand(1) # 按块生产数据并写入 for i in range(0, rows, block_size): for j in range(0, cols, block_size): # 确定当前块的实际大小 b_rows = min(block_size, rows - i) b_cols = min(block_size, cols - j) # 这一块的内容,可能是从远程读取、也可能是算法计算得到 block_data = compute_block(j, i, b_cols, b_rows) band.WriteArray(block_data, j, i)

这种做法的好处是:你永远不需要把整幅影像装入内存,每一时刻只有一个小方块在内存中。数据就像流水一样,一边算一边落盘。

6. 常见问题与排查技巧

6.1 打开文件返回None,路径没问题但打不开

这类情况通常有几个方面的原因:

TIF文件本身损坏或未完全下载。很多网盘下载的数据会缺结尾字段,GDAL打不开也正常,用专业软件验证一下,比如QGIS里若也打不开,那基本确定文件损坏。

文件路径中包含中文或特殊字符:GDAL在某些系统下对非ASCII路径支持不佳,特别是路径里夹杂了空格和中文时容易出错。解决办法是先os.chdir到目录,再用相对路径打开,或者将路径改为全英文。

文件夹权限不足,导致无法访问。

这类问题可以用一条基本命令先做快速判断:

gdalinfo 数据路径

这条命令能把所有问题暴露出来,比用代码慢慢调试高效得多。

6.2 读取的数组是错位的:形状和行列数对不上

我用conda装过很多版本,有时RasterXSize读出来是5000,但ReadAsArray返回的数组形状是(5000, 3000)而不是(3000, 5000)。许多人会因此怀疑数据有问题,注意:这是正常的。

原因是GDAL定义行列的方式是:第一维是行数(对应RasterYSize),第二维才是列数(对应RasterXSize)。如果直接按数组的shape[0]和shape[1]来当作行和列倒推,概念对应关系很可能弄混。

建议的处理方式是:需要循环时用ds.RasterYSize和ds.RasterXSize来获得统一的行列信息,不要在代码里去猜shape含义。

6.3 写入时数组维度不匹配

最容易踩的坑就是数组的shape和RasterXSize、RasterYSize的对应关系写反。WriteArray中数组是(rows, cols)二维结构,即Excel表格那样先数行再数列。而你在driver.Create里写的参数顺序是(cols, rows),也就是先宽度后高度。

解决办法就是统一出规则:创建文件时写cols和rows,用变量名直接对应放进去,不要顺手反着传。

6.4 NoData值混乱导致统计结果异常

情况是数据本身没有NoData值,但背景区域像素是0,此时计算最大值最小值时,什么数据都参与计算,统计结果便会失灵。

# 示例:NDVI取值范围本身是-1~1,但背景0参与统计后会干扰分析 band.SetNoDataValue(0) # 把0当NoData处理,但要注意如果0本身是有效值就不行

更务实的做法是用有效值掩膜:先将数组中的指定背景值剔除,再做统计。在计算时对NoData所在的区域先设成NaN,然后使用np.nanmin/np.nanmax这类函数,再从无数据干扰的数组中提取统计信息。

6.5 文件写入成功但打开是黑白的

如果写出的三波段TIF打开后是灰色或黑白的,检查一下文件头里的Photometric模型,但也不排除你的数据中3个波段的信息量本身就完全相同,此时颜色无法区分是合理现象。

另一种常见可能性是:你把三个波段的数组都写成了同一个波段,也就是三次调用都写到了band 1,导致覆盖。代码层面注意循环索引,逐波段写入时按波段号轮换,别一直停留在固定波段上。

7. 实操经验总结与扩展建议

7.1 踩过几次坑后总结的最佳实践模板

把常用流程整理一下,给出一个稍复杂但更贴近真实项目的读写模板函数,集成了打开、读信息、按窗口读取、异常处理在内的完整逻辑:

def read_tif_with_validation(path): """带校验的TIF读取,降低80%的环境问题""" if not os.path.exists(path): raise FileNotFoundError(f"文件不存在:{path}") ds = gdal.Open(path) if ds is None: raise ValueError(f"无法打开栅格文件:{path}") geo, proj = ds.GetGeoTransform(), ds.GetProjection() if not proj: print("警告:该文件没有投影信息,后续若涉及坐标转换请格外小心") return ds, geo, proj

不管环境多复杂,建议把你自己的TIF读写封装好,统一成同一签名。在团队开发里,统一入口可以避免各种五花八门的写法。

7.2 大数据量处理的性能建议

处理GB以上的栅格文件时,建议保持三个思路:

能用窗口读就不用整幅读。像是做统计、直方图这些,GDAL其实内部可以按块遍历,比如你完全可以用band.ComputeStatistics()获得全图统计值,而无须把图一次性读入内存。这样读取速度会更快,内存占用也更稳定。

合理设置Cache。GDAL本身有环境变量GDAL_CACHEMAX用于控制缓存大小,默认值可能很小。在机器内存大的条件下,可以适当调大,比如512MB或更大。

gdal.SetCacheMax(2 * 1024 * 1024 * 1024) # 设置2GB缓存

注意:这个只是建议值,并不是设得越大越快。如果影像和窗口较小,设太大反而会因为内存占用过高,让系统更慢。

栅格算法记得先想清楚用哪些内存优化策略来做分块处理,尽量让内存占用平稳。做得好的话,计算机几百GB的大数据集也可以稳定运行。

7.3 后续扩展:从单张TIF到批处理与COG

如果现在你手里的TIF文件不止一张,而是几十、几百个,靠手动改文件名一个一个跑就不划算了。这时候你需要做一个批处理目录:

import glob tif_list = glob.glob(r"data\*.tif") for tif in tif_list: out_name = os.path.splitext(os.path.basename(tif))[0] + "_processed.tif" process_single(tif, os.path.join("output", out_name))

另外,数据发布或共享场景下,建议直接生成COG(Cloud Optimized GeoTIFF)。COG是一种内部存储结构优化的GeoTIFF,添加了更细的分块信息和索引机制,让云端和本地按需读取局部数据时效率明显提升,等效于给文件上了电梯,不需要整楼搬运。

# 将普通TIF转为COG gdal.Translate("output_cog.tif", "input.tif", format="COG", options=["COMPRESS=LZW"])

这个操作配合分块读取模式,在Web地图服务或大型影像管理场景下,使用体验会好很多。

个人在实际项目里的体会是,GDAL读写TIF这件事,代码层面说到底就那么几个关键点:坐标系与仿射变换要理解透、读写顺序要用标准化模板、NoData和数据类型要时刻确认、大文件操作要养成窗口化和主动刷新的习惯。框架搭建清楚后,大部分问题都能被归入有限几个模式,排查路径也就变得简单了。真遇到网上查不到答案的难题也不用慌,按上面梳理的顺序一步步排查,绝大多数问题都能被定位到具体环节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询