资讯动态

Python实现GeoTIFF批量重投影与重采样:从原理到实战避坑

发布时间:2026/9/9 1:27:34 来源:尧图企业网站定制
朋友做水环境分析手里攒了四十多个GeoTIFF有的是WGS84经纬度有的是国家2000还有几个老数据用的地方坐标分辨率从0.5米到90米参差不齐。他问我要不要一个个拉进QGIS里手动转。我说这种重复劳动别折腾了直接写一段Python脚本重投影、重采样一起解决以后再来新文件改个路径就能跑。这篇文章就是那次处理的完整记录包括核心原理、代码实现、批量处理方法和几个我踩过的坑。这套方法适合刚接触栅格处理的GIS、遥感、测绘、水文方向的同学也适合需要把多源数据统一到同一套坐标系和分辨率下再做分析的人。就算你平时主要用ArcGIS或者QGIS把这里的原理吃透了也能少设错不少参数。1. 先把思路理清楚需求拆解与方案选型1.1 动手之前先回答三个问题处理tiff栅格之前先别急着写代码先搞清楚三个问题输入数据长什么样、输出给谁用、目标分辨率和坐标系怎么定。输入数据长什么样指的是每个tiff的波段数、位深、有无nodata值、当前CRS是否存在且正确。很多老数据或者从某些设备导出的tiff坐标参考信息是缺失的这类数据如果直接做重投影rasterio会直接报错或者输出一堆乱码一样的像元。建议先做一个数据清单把每个文件的原始信息打印出来看一看。输出给谁用决定了目标CRS和位深怎么设。如果只是自己在ArcGIS里看一般保持原有数据精度即可如果要和别的高精度影像叠加做分析就得统一到高精度底图的坐标系如果是要给模型用可能还需要把nodata值、像素类型做统一规定。目标分辨率和坐标系怎么定这是最重要的一步。不要拍脑袋选先看项目需求或任务书有没有规定没有的话再根据数据来源的精度等级来定。比如我自己那次目标坐标系定的是UTM 50N因为研究区在东部跨越的经度范围不大UTM投影的变形可以接受目标分辨率定的是10米因为最粗的数据源是90米重采样到10米虽然没有增加真实信息量但至少能把网格对齐。1.2 为什么用Python而不是GDAL命令行或QGIS很多人可能会说这个需求用QGIS的批量工具或者GDAL命令行也能做为什么非要写Python。QGIS的批量处理工具确实很方便但问题在于第一可视化操作记录不下逻辑下次换个区域换批数据还得重新点一遍第二如果中间要加一些条件判断、重命名规则、质量统计在GUI里做会很啰嗦第三当你有几十上百个文件时QGIS可能会把内存吃满而脚本可以控制按窗口读取。纯GDAL命令行比如gdalwarp -t_srs EPSG:32650 -tr 10 10 -r bilinear input.tif output.tif其实已经完全能做重投影和重采样了。但如果要做多波段特殊处理、逐像元统计、异常值排查、串联到整个分析流程里命令行写起来会非常痛苦还得反复调用子进程传参数调试成本很高。rasterio的优势在于它底层虽然还是GDAL但面向Python用户封装出了更符合直觉的API用rasterio.open打开文件用src.read()把波段读成numpy数组用rasterio.warp.reproject做重投影。这意味着可以轻松地把栅格数据接进numpy、pandas、scikit-learn整个Python生态里。所以我的建议是简单到极致的单文件转换用gdalwarp就行一旦涉及批量、条件逻辑、后续分析直接用Pythonrasterio。1.3 环境安装与版本坑rasterio的安装看起来简单实际上很多人第一步就翻车。由于rasterio依赖了系统级的libgdal如果直接用pip安装经常会出现libgdal.so.xx: cannot open shared object file这类错误本质上就是Python包里的GDAL版本和你系统里预装的GDAL版本对不上。我自己的习惯是用conda创建独立环境直接从conda-forge通道装conda create -n geo python3.10 -y conda activate geo conda install -c conda-forge rasterio pyproj numpy -y如果一定要用pip可以考虑安装预编译的wheel比如pip install rasterio通常会自动匹配但如果系统里已经装了别版本GDAL还是建议用conda环境隔离省去后面的烦心事。这里插一个经验我见过很多人处理tiff数据时习惯用PIL.Image或者matplotlib去读图。这些库做图像显示没问题但处理地理坐标就完全使不上劲了因为它们基本只关心像素和颜色不理会GeoTIFF标签里的坐标信息。栅格数据的地理处理老老实实用rasterio/GDAL这套生态。2. 重投影、重采样与CRS不懂原理也会用但懂了才能不踩坑2.1 CRS到底在说什么CRS的全称是Coordinate Reference System中文叫坐标参考系统。它要回答两个问题你在这颗星球上的位置用什么样的坐标来表达以及这个坐标是怎么从地球表面落到平面上的。拿生活化的场景类比经纬度WGS84像是全球通用的“街道地址”每个地方都有且只有一个地址字符串投影坐标系则像是把这个地址翻译成“第几大道第几街”换一个投影方式就换一套当地的门牌编号规则。重投影就是把一套门牌编号翻译成另一套门牌编号。为什么不能直接改tiff里的坐标数字因为GeoTIFF里存的不仅仅是X、Y那两列坐标还有一个仿射变换矩阵transform用来定义像元左上角坐标、像元尺寸、旋转角度另外还有一组投影参数定义椭球体、中央经线、假东假北这些信息。光改坐标值不改投影参数就好比只改了门牌号却不告诉你这个门牌号属于哪条街数据落在哪根本对不上。所以在实际项目中我接手任何一批数据的第一件事就是用src.crs看它带没带CRS信息以及带的是不是我预先判断的那套坐标系。有些数据虽然声明了EPSG:4326但其实是经纬度顺序颠倒或者本来应该是Web墨卡托却标成了WGS84这种数据直接重投影会错得很离谱。2.2 重投影时最容易忽略的单位和范围问题重投影的本质是每个地面点从旧的坐标系统通过反解经纬度再转入新坐标系统所以理论上同一个点在新的投影下有且仅有一个新坐标。但重投影并不仅仅是改坐标它还要重建整个像元网格。举个例子一份EPSG:4326的tiff像素分辨率写的是0.01度。如果目标坐标系是UTM 50N单位是米那0.01度约等于1110米左右。但如果你在设定输出分辨率时直接写0.01输出结果就会被解释成0.01米文件尺寸会膨胀到难以置信处理时长也跟着爆炸。我在实操中见过不少次这样的情况目标输出尺寸算出来几亿行乘几亿列程序卡死或者报内存错误。所以代码里一定要确认好目标CRS的单位再用合适的米制分辨率或者干脆只指定目标CRS让calculate_default_transform按源数据的像元尺寸重新计算一个目标尺寸出来。重投影带来的另一个隐藏影响是范围变化。原本在WGS84下是一个经纬度矩形转成UTM投影后可能是一个带角度的四边形为了存储方便输出tiff的范围通常会取这个四边形的最小外接矩形于是边缘会出现一圈nodata区域。如果后续做面积统计、像元覆盖分析一定要记得把这部分nodata排除掉不然统计结果会偏。2.3 重采样算法选择不是追求“高精度”就选贵的重采样解决的问题很具体像元网格从旧网格变成新网格后新网格里的每个像元值从哪里来。这是个重排和插值的过程。常用算法就四种我直接给结论算法原理适用场景注意事项nearest取距离新像元中心最近的旧像元值土地利用、植被分类、掩膜等离散分类数据边缘会有锯齿但不会产生新值bilinear取周围四个像元做线性加权平均DEM、温度、降水量等连续变量对异常值敏感会平滑掉一些细节cubic取周围16个像元做三次卷积插值遥感影像反射率、高精度地形计算量较大可能产生超出原始值域的结果比如负的反射率lanczos更高阶的窗口卷积插值做制图输出、图像增强速度慢对nodata区域影响范围更大很多新手有个误区觉得算法等级越高越高级拿到什么数据都上cubic或者lanczos。其实分类数据一旦用bilinear插值类别值就会变成中间值比如原来的1代表林地、2代表草地插值完可能出来个1.37这种类别就废了。所以我的原则很简单分类数据用nearest连续数值的数据用bilinear起步只有在平滑要求极高的制图场景才用cubic和lanczos。另外还要留心nodata对重采样结果的影响。如果源数据里有大范围的nodata用bilinear或cubic时插值窗口会把这些nodata像元也参与计算导致边缘出现一圈异常值比如水面高程变成负数。处理办法是在重投影时明确设置src_nodata和dst_nodata必要时先对nodata区域做掩膜重采样完再恢复。3. 完整实操用rasterio实现单文件重投影、重采样3.1 先探明源数据基本信息写代码前先把源数据的基本信息摸清楚。我这里以一份国界附近的DEM数据为例源文件是WGS84经纬度坐标分辨率0.000833333度。import rasterio with rasterio.open(input_dem.tif) as src: print(CRS:, src.crs) print(尺寸(宽x高):, src.width, x, src.height) print(像元大小(res):, src.res) print(范围(bounds):, src.bounds) print(波段数:, src.count) print(数据类型:, src.dtypes) print(nodata:, src.nodata)输出类似这样CRS: EPSG:4326 尺寸(宽x高): 7200 x 3600 像元大小(res): (0.0008333333333333334, 0.0008333333333333334) 范围(bounds): BoundingBox(left106.0, bottom30.0, right112.0, top36.0) 波段数: 1 数据类型: (float32,) nodata: -9999.0通过这一步我能确认三件事源数据带CRS且确实是WGS84nodata值存在是-9999数据类型是float32输出时最好保持float32或者转成int16别随便转int否则高程精度会受损。3.2 核心代码计算目标transform并执行重投影接下来是核心重投影重采样代码。这里的思路是先用calculate_default_transform根据源数据的范围和目标CRS算出一组合适的目标transform和输出宽高然后逐波段调用reproject。import rasterio from rasterio.warp import calculate_default_transform, reproject, Resampling src_path input_dem.tif dst_path output_dem_utm50_10m.tif dst_crs EPSG:32650 resolution 10 # 单位米 with rasterio.open(src_path) as src: transform, width, height calculate_default_transform( src.crs, dst_crs, src.width, src.height, *src.bounds, resolutionresolution ) kwargs src.meta.copy() kwargs.update({ crs: dst_crs, transform: transform, width: width, height: height, compress: lzw, # 输出时压缩减小文件体积 }) with rasterio.open(dst_path, w, **kwargs) as dst: for i in range(1, src.count 1): reproject( sourcerasterio.band(src, i), destinationrasterio.band(dst, i), src_transformsrc.transform, src_crssrc.crs, src_nodatasrc.nodata, dst_transformtransform, dst_crsdst_crs, dst_nodatasrc.nodata, resamplingResampling.bilinear, num_threads2, )这段代码的核心点在于calculate_default_transform把两件麻烦事一次性解决了它根据源数据的bounds和要转换的CRS算出了目标坐标系下的外接矩形又根据我传入的resolution10算出了输出图的宽度和高度。这样一来我就不用手动去转换四个角点再做外包矩形了。逐波段循环处理是因为rasterio的reproject要求source和destination对齐一次处理一个波段即使波段数很多也不会占用过大内存。如果源是有RGB三个波段的影像这里循环三次就会自动处理完。3.3 关键参数逐项说明有几个参数值得展开说因为网上很多教程都是一笔带过实际用起来全是坑。resolution参数我传的是10单位是米这是因为目标CRS是UTM。如果我不传这个参数calculate_default_transform会试图保留源数据的分辨率数值但源分辨率是度数值0.000833333在米制的UTM里会输出一个尺寸极其夸张的文件。所以要么明确传分辨率要么先确认两个CRS的单位一致。顺带说一句如果你确实想让目标分辨率跟源数据的实际地面分辨率接近可以用度转米的近似公式在WGS84下1度约等于111320米乘以纬度余弦值但最稳妥的办法还是先转过去看一眼结果的res值。src_nodata和dst_nodata必须要传尤其是源数据里存在-9999这种特殊值时。如果漏掉重采样时nodata区域的像元值会参与插值生成一圈假数值输出后再做统计很容易把负值带进结果里还找不到原因。num_threads2是一个实用的小优化它会启用GDAL的多线程重采样对高分辨率tiff的提速非常明显。不过也不是线程越多越好实测数据量中等的情况下2到4个线程收益最大再多反而会增加调度开销。3.4 测试用例EPSG:4326转到UTM 50N我拿一份小范围DEM做完整测试。源数据范围是经度106到112纬度30到36分辨率约90米转成UTM 50N后目标分辨率定30米。跑完代码再看输出文件import rasterio with rasterio.open(output_dem_utm50_10m.tif) as dst: print(CRS:, dst.crs) print(尺寸:, dst.width, x, dst.height) print(像元大小:, dst.res) print(范围:, dst.bounds) print(nodata:, dst.nodata) print(数据类型:, dst.dtypes)输出符合预期CRS: EPSG:32650 尺寸: 43057 x 29891 像元大小: (30.0, 30.0) 范围: BoundingBox(left191656.64, bottom3321409.31, right1482766.64, top4218139.31) nodata: -9999.0注意这个尺寸对应的文件如果不压缩会很大所以我顺手加了compresslzw。实测同样数据LZW压缩后体积能减少50%以上读取速度影响也不大建议日常输出都加上。4. 批量处理与质量验证让脚本真正落地4.1 多文件批量处理脚本单文件处理跑通了批量就简单了。我一般用pathlib遍历文件夹再封装成一个函数。from pathlib import Path import rasterio from rasterio.warp import calculate_default_transform, reproject, Resampling def reproject_resample(src_path, dst_path, dst_crs, resolution, resamplingResampling.bilinear): with rasterio.open(src_path) as src: transform, width, height calculate_default_transform( src.crs, dst_crs, src.width, src.height, *src.bounds, resolutionresolution ) kwargs src.meta.copy() kwargs.update({ crs: dst_crs, transform: transform, width: width, height: height, compress: lzw, }) with rasterio.open(dst_path, w, **kwargs) as dst: for i in range(1, src.count 1): reproject( sourcerasterio.band(src, i), destinationrasterio.band(dst, i), src_transformsrc.transform, src_crssrc.crs, src_nodatasrc.nodata, dst_transformtransform, dst_crsdst_crs, dst_nodatasrc.nodata, resamplingresampling, num_threads2, ) input_dir Path(raw_tiffs) output_dir Path(processed_tiffs) output_dir.mkdir(exist_okTrue) for src_path in input_dir.glob(*.tif): dst_path output_dir / f{src_path.stem}_utm50_10m.tif reproject_resample(src_path, dst_path, EPSG:32650, 10) print(f已处理: {src_path.name})这段脚本里注意两点输出文件名我用stem取了源文件名并加了后缀这样不会覆盖源文件后续排查也方便dst_path必须提前用mkdir(exist_okTrue)建好目录否则rasterio打开写入路径时会因为目录不存在而报错。如果文件数量很大可以考虑用concurrent.futures.ProcessPoolExecutor做多进程并行。要注意rasterio在多进程下每过一段时间会出一些玄学问题比如句柄泄漏或者环境变量冲突稳妥的做法是每个子进程内部独立打开文件不要跨进程共享同一个src句柄。我一般只在处理超过100个文件时才会用并行策略文件少的情况下单线程反而更快因为多进程的启动开销也是成本。4.2 输出质量验证不能只看一眼图处理完的tiff一定要验证不能只看文件生成成功就觉得万事大吉。我每次会做三道检查。第一道是用rasterio重新打开输出文件检查CRS、尺寸、res和bounds是否符合预期。很多问题在这一步就会暴露比如尺寸写到几千万像素就是因为目标分辨率单位搞错了。第二道是统计有效像元占比和值域范围对比源数据。这个主要防两件事一是nodata被插值成全黑区域二是重采样把值域搞到不合理区间。比如DEM数据源数据高程范围是20到3000米输出结果如果出现负几万的值那基本可以断定nodata区域参与了插值或者cubic算法过冲了。第三道是视觉叠加验证。把处理完的tiff拖进QGIS叠加OpenStreetMap底图或者已经配准好的影像再配合一定透明度设置看道路、水系这些线状地物是否对得上。这一步虽然原始但往往能发现坐标系声明错误导致的整体偏移问题。4.3 大文件处理与内存优化rasterio的默认行为是整幅图读取遇到几十GB的大影像会把内存吃干。解决方案一个是前面提到过的逐波段写另一个是按窗口读取重投影。核心思路是把目标范围切成一块块tile每块只处理局部数据最终写入目标数据集。代码上可以借用rasterio.warp.reproject的src_window和dst_window参数但那样写起来复杂度会上一个台阶。日常处理如果只是几十个文件单个文件在2GB以内前面的逐波段方案完全够用。真正需要按窗口处理的大文件建议直接考虑rasterio.vrt.WarpedVRT配合subset按需读取要用的区域。另外GDAL本身有个环境缓存参数叫GDAL_CACHEMAX默认很小对于反复读写的场景效果不明显。如果处理过程中频繁出现磁盘I/O瓶颈可以用rasterio.Env(GDAL_CACHEMAX512)在代码块里临时调大缓存实测对重投影速度有明显帮助。5. 常见问题排查与避坑实录5.1 错误速查表我在实际使用中整理了一份报错排查表遇到问题先对表查找比网上瞎搜高效得多。现象原因解决办法The dataset has no coordinate system源tiff的CRS标签缺失用gdal_edit.py或rasterio重新定义CRS后再重投影输出文件尺寸爆表目标CRS单位是米分辨率却填了度数值确认目标CRS单位传正确的米制分辨率输出全是黑色或全0nodata值参与重采样后被赋成0或者数据类型不匹配显式设置src_nodata和dst_nodata并检查输出数据类型分类结果出现小数类别对分类数据用了bilinear/cubic插值分类地球物理数据类型一律用nearest重投影后图形整体偏移源数据的CRS申明与实际投影不符用已知控制点验证必要时单独定义源CRStiff在其他软件里显示黑白或颜色不正数据位深、波段数和色彩解释标签的问题输出3波段8bit并明确ColorInterp为RGB5.2 为什么Hypack这类软件加载tiff是全黑的这个场景其实不止Hypack不少海事测量、工程软件都会碰到。tiff在GIS软件里正常进了行业软件变成全黑或黑白原因往往有三个。第一个原因是tiff是单波段浮点型这些软件默认按8bit整数纹理加载浮点数据超过1.0全部被当成最大值结果就是整幅图亮白一片。第二个原因是nodata值没有内嵌到tiff标签里软件把-9999当成一个正常数值参与渲染一压色阶就把有效范围压没了。第三个原因是色彩解释标签缺失软件不知道这应该是RGB还是灰度。解决办法很简单输出给这类软件用的tiff时先读进来把数据归一化到0到255范围转成8bit再写成三波段RGB数据集。注意还要顺手把colorinterp设置成red、green、blue这样别的软件打开就知道是彩色图。import numpy as np import rasterio from rasterio.enums import ColorInterp with rasterio.open(dem_float32.tif) as src: data src.read(1) nodata src.nodata mask data ! nodata data_valid np.where(mask, data, np.nan) dmin, dmax np.nanmin(data_valid), np.nanmax(data_valid) norm (((data_valid - dmin) / (dmax - dmin)) * 255).astype(uint8) norm np.where(mask, norm, 0).astype(uint8) rgb np.stack([norm, norm, norm], axis0) profile src.profile.copy() profile.update(dtypeuint8, count3, nodata0) with rasterio.open(dem_rgb_8bit.tif, w, **profile) as dst: dst.write(rgb) dst.colorinterp [ColorInterp.red, ColorInterp.green, ColorInterp.blue]这段代码里norm np.where(mask, norm, 0)非常关键它把nodata区域强制设成0而不是插值结果避免软件渲染时把边缘的杂散值卷进来。5.3 老数据没有CRS怎么办源tiff没有CRS是重投影里最烦的问题。比如一些早期设备导出的文件或者从某个系统里导出的DEMCRS信息被剥离了。处理思路是先确定数据的真实CRS。最常用的是通过已知地理特征反推坐标打开QGIS加载这份tiff再叠加一份全球底图观察数据的偏移量。如果实际经纬度坐标与数据里的X、Y对得上那很可能就是WGS84经纬度如果目标区域正好落在UTM某个分带的范围内且坐标值也在合理的假东、假北范围内那基本就是UTM。确定后用rasterio.shutil.copy配合dst_kwargs写回CRS即可相当于对原文件做一个格式重写。或者用GDAL自带的gdal_edit.py -a_srs EPSG:xxxx input.tif一步解决。一定要先确定再动手千万别拿一份坐标系不明的数据直接重投影那相当于在错误的门牌号上翻译地址结果只会错上加错。5.4 我踩过最深的坑重投影后黑边有一段时间我做流域分析处理完的DEM总是四周带了一圈0值黑边范围统计时总面积虚高算坡度的时候边缘还会出现各种畸形栅格。排查后发现是重投影后外接矩形的nodata区域没参与有效值掩膜计算。这种黑边问题最好的规避方式是在做分析和统计前明确用src.read(maskedTrue)读取带掩膜的数据让nodata区域在numpy里直接变成nan或者在数值计算时先构建数据有效掩膜后续所有统计都基于这个掩膜。不要指望重投影时一行代码把所有边缘问题都解决因为投影后的外接矩形区域天然就是比实际数据范围大一圈。最后分享一个我的习惯处理完tiff数据我一定会再写一个十行左右的小函数统计输出文件的有效像元数、均值、标准差和范围拿它跟源数据做对比。数值关系基本对得上才说明这次重投影、重采样没有把数据搞坏。这个习惯帮我抓出过好几次插值导致的异常尤其是用cubic处理DEM时产生的负高程值一定得通过统计才能快速暴露。日常大批量处理栅格时我还会在脚本里顺手输出一个处理日志记录每个文件的输入输出路径、CRS、尺寸、处理耗时。这样出了问题可以直接回溯不会对着屏幕发懵。Python做tiff重投影、重采样技术门槛真不高真正决定数据质量的是处理前对数据情况的排查、处理中参数的合理设置以及处理后不偷懒的验证。希望这篇文章能让你少走一点我走过的弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价