刚接一批多时相影像处理需求时,我第一时间想到的就是把“重投影”这件事做成流水线。用IDL调用ENVI接口做GLT重投影,这块我前前后后用过很多次,熟悉到什么程度呢:哪怕换了新电脑,第一件事也是把这段GLT流程代码翻出来跑通。GLT,全称Geographic Lookup Table,翻译过来就是地理查找表。它不直接帮你改投影,而是先算好一张“映射表”,再拿这张表去逐像元取原图数据。这种先建表、再套表的方式,在批处理场景下特别划算,尤其是面对一二十景、甚至上百景需要统一到同一网格的影像时,效率差距非常明显。
这篇内容主要写给两类人:一类是刚接触IDL和ENVI二次开发的遥感方向学生,另一类是需要在生产环境里批量做几何校正的工程师。如果你只是偶尔用一两次ENVI可视化界面,那这篇也能帮你理解界面背后到底发生了什么。我会把原理、代码骨架、完整案例和排查经验都摊开讲。文章里给的是我实际跑通过的方式,不同ENVI版本在关键字细节上可能会有差异,但是整个思路是通用的。
1. GLT重投影原理与为什么绕这么一圈
1.1 GLT到底是什么:把几何校正拆成了“两张表”
很多人第一次接触GLT会懵:为什么重投影还要先搞一个查找表?直接按目标影像的四个角点算个仿射变换然后重采样不行吗?行,但你只处理一景影像时当然没问题,一旦数据量上来,事情就变了。
GLT的核心思想很简单:把“输出影像的每个像素点到底该取输入影像的哪个像素”这件事,预先计算好,存成一张表。这张表本身是一个栅格文件,通常有两个波段:第一个波段存X方向上的映射坐标,第二个波段存Y方向上的映射坐标。举个例子,输出影像的某个像素位于第100行、第200列,GLT中该位置的波段1数值是135.6,波段2数值是78.2,意思就是:你去输入影像的第78.2行、第135.6列附近取值,填到输出影像的这个位置。如果那个像素在输入影像范围之外,GLT里对应值就会写成-1,表示“这里没有有效数据”。
你可以把GLT理解成一本字典:别人查一个单词要翻半天,你把这本字典里的索引提前建好,以后每次查询都是索引查找,时间复杂度从线性降到了常数。遥感影像重投影也是这样,几何关系一旦固化下来,就不需要每次重算复杂的投影变换公式了。
而且GLT表不只支持标准的坐标转换,它本质上支持任意行列映射关系。比如传感器本身有旋转、有扫描畸变,只要你能算出一个输入到输出的对应关系,就能写进GLT。这也是为什么很多做无人机影像、侧视雷达影像处理的老代码,最后都落到GLT这条思路上。
1.2 直接重投影和GLT的取舍:什么时候必须用它
我会不会用GLT,一般看三个条件。
第一个条件,数据量。单景单波段,几百乘几百的小图,直接重投影和GLT差距不大;但如果是10个波段、1万乘1万的影像,重投影时的每个输出像素都要反算投影公式,这中间会包含三角函数、椭球参数迭代等计算,跑起来非常吃力。GLT把最重的计算一次性做完,之后每个波段都只是查表采样,速度差一个数量级。
第二个条件,多时相或多传感器。比如同一个区域由同一颗卫星连续拍摄了30天,每天一景,你要把它们全部投影到同一张参考网格上。如果用常规方式,每一景都需要重新做一次坐标反算;如果先生成一个GLT,然后循环30次应用同一张GLT,计算量只花在采样上。我处理模拟项目X的数据时,就是靠这个方式把全流程耗时缩到了原来的三分之一。
第三个条件,是否需要精确控制重采样方式。GLT应用阶段可以统一指定最近邻、双线性或三次卷积,这比在界面里一景一景设置要稳定得多,避免人工误操作。
当然,GLT也不是没有坑。GLT文件本身比较大,两个浮点波段会占不少磁盘空间;而且GLT只是把映射关系存下来,不包含任何原影像像素值,所以它不能脱离原始影像独立使用。这两个特点决定了它更适合“一次生成、多次复用”的场景,而不是“一次性用完就扔”的小处理。
1.3 ENVI里的GLT本质也是这两步
在ENVI图形界面里,对应的操作大家可能都见过:一个是Build GLT,另一个是Georeference from GLT,有些汉化版叫“构建地理查找表”和“根据GLT几何校正”。Build GLT就是生成查找表,Georeference from GLT就是应用查找表完成重投影。菜单里做的事情,和IDL代码里调用的底层例程是一一对应的,分别是glt_image_doit和georef_glt_doit。
所以你在IDL里写代码,并不是另搞一套独立于ENVI的方法,而是把ENVI界面里的操作自动化了。这个理解很重要,因为很多参数的含义,你在ENVI界面上操作时可能没细想,但换到代码里,就变成必须显式写入的关键字了。接下来我拆一下代码骨架。
2. IDL调用ENVI接口的代码骨架与关键参数
2.1 初始化ENVI经典模式
IDL调用ENVI接口,第一步永远是打开一个合法的ENVI会话。如果你做过二次开发,应该很熟悉这行:
envi, /restore_classic/restore_classic表示恢复经典ENVI模式,这样后续的ENVI_OPEN_FILE、ENVI_FILE_QUERY、ENVI_DOIT这些老接口才能正常工作。ENVI 5.x之后,新版接口大量使用面向对象风格,但也保留了经典接口兼容层。如果你用的是新版ENVI,IDL启动后会弹出一个ENVI session选择框,我一般直接点确定,进入带经典接口的会话,代码就不用大改。
打开会话之后,还有一个细节容易被忽略:一定要确保ENVI_INITIALIZE已经执行过。经典版本里,第一次调用envi会自动初始化;但在某些新版ENVI中,建议开头先调用一下envi_initialize,否则后面ENVI_OPEN_FILE可能报“ENVI not initialized”错误。稳妥起见,我会先写:
envi_initialize envi, /restore_classic虽然有些机器上envi_initialize会提示已初始化,但不影响流程。
2.2 关键代码段的角色分工
整个GLT重投影流程,按顺序分三步:打开原始影像并查询基本信息;生成GLT;应用GLT写结果。我在工程里写的代码,基本框架如下。
打开原始影像:
envi_open_file, src_file, r_fid=src_fid if src_fid eq -1L then return envi_file_query, src_fid, dims=src_dims, ns=ns, nl=nl, nb=nbENVI_OPEN_FILE返回的src_fid是文件标识号,-1表示打开失败。ENVI_FILE_QUERY则拿到了一堆关于行列和波段的信息,dims这个数组是所有ENVI子集操作都要用的:dims[0]不用管,dims[1]和dims[2]表示列方向起始和结束,dims[3]和dims[4]表示行方向起始和结束。默认就是全图[0, 0, ns-1, 0, nl-1]。
然后是生成GLT,这是核心调用:
envi_doit, 'glt_image_doit', $ in_fid = src_fid, $ dims = src_dims, $ pos = [0], $ ref_fid = ref_fid, $ ref_dims = ref_dims, $ out_name = glt_file, $ out_bname = ['GLTX', 'GLTY']这里有几个关键点。in_fid是你待重投影的源影像的FID,ref_fid是你希望输出网格对齐到的参考影像的FID,out_name则是GLT文件的输出路径。参考影像必须有完整的地图信息(map info),也就是它自己已经带有准确的地理坐标和投影,ENVI才能根据参考影像的网格来构建映射关系。pos这一步只取源影像的一个波段参与几何计算即可,因为几何关系对所有波段是相同的,我通常写成[0]。
应用GLT:
envi_open_file, glt_file, r_fid=glt_fid envi_file_query, glt_fid, dims=glt_dims envi_doit, 'georef_glt_doit', $ in_fid = src_fid, $ in_dims = src_dims, $ in_pos = indgen(nb), $ glt_fid = glt_fid, $ glt_dims = glt_dims, $ out_name = out_file, $ background = 0, $ /linearin_pos这里用了indgen(nb),意思是把原始影像的所有波段都拿来重投影。如果你的源影像有5个波段,就想都输出5个波段,那in_pos就是[0,1,2,3,4];如果只想输出其中两个波段,就写[0,2]。注意glt_dims必须是GLT文件自身的尺寸,不能和src_dims搞混,否则会报“dimension mismatch”。
/linear表示用双线性重采样方式应用GLT。这个关键字是可选的。你也可以写/nearest用最近邻,或者/cubic用三次卷积。具体到实际业务里,我后面再细说怎么选。
2.3 自定输出网格时的另一种生成方式
上面这一套是“以某景参考影像为准”的做法。有些时候,你手头没有一景合适的参考影像,只想把结果投影到一个自己定义的标准网格上:比如经纬度每0.01度一个像元,范围是东经110到115度、北纬35到40度。这种情况下glt_image_doit也能做,但需要额外传入输出网格的起止坐标、像元尺寸和投影信息。
我通常的做法是先用ENVI_PROJ_CREATE或从已有影像中获取投影对象,再通过ENVI_MAP_INFO_CREATE构造一个map info结构体,最后在调用glt_image_doit时传入map_info。大致是这个结构:
proj = envi_proj_create(/geographic) map_info = envi_map_info_create(proj=proj, mc=[110.0d, 40.0d], tiepoint=[0, 0], xsize=0.01d, ysize=0.01d, /geographic) envi_doit, 'glt_image_doit', $ in_fid = src_fid, $ dims = src_dims, $ pos = [0], $ map_info = map_info, $ out_name = glt_file这种方式的麻烦在于:mc是地图左上角坐标还是像元中心坐标,不同版本的ENVI解释并不完全一致,容易出现半像元的整体偏移。如果你不是业务上必须“裸写网格”,我建议优先用参考影像方式,少踩一个坑。真要自定网格,做完之后一定用ENVI打开结果,和已知的界标影像叠一下看偏移量。
3. 实操案例:把无投影影像重投影到参考网格
3.1 场景复现与准备工作
为了把流程讲透,我构造一个典型场景:假设你手头有一个模拟项目X生成的影像,文件名叫raw_sim.dat,这个文件本身没有正确的地理投影信息,或者投影坐标和参考数据不一致。现在需要把它的三个波段全部重投影到同一地区的一景参考影像网格上,参考影像叫ref_image.dat,它带有标准的地图信息。
这种场景在实际工作中很常见。比如你从某平台下载了一个公开数据集,或者自己做了仿真模拟,输出的栅格没有地理坐标,而你的研究区正好有另一景带坐标的历史影像,这时最省事的方法就是把仿真影像“嵌”到历史影像的坐标框架里去。
动手之前先检查两个文件:
- 参考影像必须能用ENVI打开,并且它的头文件里已经有map info,没有的话先通过“Edit Header”补上。
- 源影像即使没有地图信息,也至少要能正常显示,不能是损坏文件。源影像的地图信息缺失不会阻止GLT生成,因为GLT是用行列映射关系工作的,只是最后校正完的结果会完全依赖参考影像的网格。
3.2 完整可运行代码与逐段说明
下面这段代码是我实际跑通过的,我建议你复制到IDL里之后,先不要急着换成自己的数据,用随手生成的两幅小尺寸模拟影像跑一遍,确认组件都正常工作后再换正式数据。
pro glt_demo_workflow compile_opt idl2 ; 初始化ENVI会话 envi_initialize envi, /restore_classic ; 选择源影像 src_file = dialog_pickfile(title='选择待重投影的源影像') if src_file eq '' then return envi_open_file, src_file, r_fid=src_fid if src_fid eq -1L then begin print, '源影像打开失败' return endif envi_file_query, src_fid, dims=src_dims, ns=ns, nl=nl, nb=nb ; 选择参考影像 ref_file = dialog_pickfile(title='选择参考影像') if ref_file eq '' then return envi_open_file, ref_file, r_fid=ref_fid if ref_fid eq -1L then begin print, '参考影像打开失败' return endif envi_file_query, ref_fid, dims=ref_dims, ns=ref_ns, nl=ref_nl ; 第三步:生成GLT查找表 glt_file = dialog_pickfile(title='保存GLT文件', filter='*.dat', /write) if glt_file eq '' then return envi_doit, 'glt_image_doit', $ in_fid = src_fid, $ dims = src_dims, $ pos = [0], $ ref_fid = ref_fid, $ ref_dims = ref_dims, $ out_name = glt_file, $ out_bname = ['GLTX', 'GLTY'] if file_test(glt_file) eq 0 then begin print, 'GLT文件生成失败' return endif ; 第四步:打开GLT文件 envi_open_file, glt_file, r_fid=glt_fid if glt_fid eq -1L then begin print, 'GLT文件打开失败' return endif envi_file_query, glt_fid, dims=glt_dims, nb=glt_nb ; 第五步:应用GLT,对源影像所有波段进行重投影 out_file = dialog_pickfile(title='保存重投影结果', filter='*.dat', /write) if out_file eq '' then return envi_doit, 'georef_glt_doit', $ in_fid = src_fid, $ in_dims = src_dims, $ in_pos = indgen(nb), $ glt_fid = glt_fid, $ glt_dims = glt_dims, $ out_name = out_file, $ out_dt = 4, $ background = 0, $ /nearest print, 'GLT重投影完成:', out_file end来来回回就是这些调用,但有几个参数我特意在案例里做了明确选择,这里解释一下为什么。
out_dt = 4表示输出数据类型为浮点型。如果源影像本身是整型,你可能希望输出保持整型,那out_dt就要设成源影像的数据类型。很多场景下,重投影之后还要做波段运算,浮点型反而更方便,所以我这里直接指定了浮点。数据库类型编号对应关系是:1字节、2整型、3长整型、4浮点、5双精度、6复数、9无符号整型等等,记不住没关系,在ENVI的帮助里搜“ENVI Data Type”就能查。
/nearest是最近邻重采样。选择它不是因为精度最好,而是最稳妥。如果你处理的是分类图、掩膜这类离散值数据,绝对不能用双线性或三次卷积插值,那样会插出本来不存在的类别值,只有最近邻能保持原始像元的类别属性。如果是连续值数据(比如反射率、温度),用/linear或/cubic会更平滑。我在这里先写了最近邻,是因为对第一次跑通流程的读者来说,最近邻结果更不容易因为边缘插值产生诡异的中间值干扰判断。
background = 0控制无效区域填充值。GLT中映射到-1的像元,最后输出时用的就是这个背景值。0在大多数影像中是个安全的默认值,但如果你源影像本身有很多真实值为0的像元,建议换成其他值(比如-9999),否则后续做统计的时候会把无效区混进来。
3.3 验证重投影结果是否靠得住
跑完流程不能直接就走,至少做三层验证。
第一层,看范围对不对。在ENVI里打开输出结果和参考影像,打开两个视图联动,先看边界范围是否一致。标准网格输出结果的四个角点应该和参考影像重合,差半个像元以上就要警惕。
第二层,看细节对没对上。把参考影像和重投影结果以一定透明度叠加显示,找几个明显的地物特征,比如河流交叉口、田块边缘、建筑物阴影,观察是否对齐。这一步能有效发现半像元偏移和投影参数错误。
第三层,做数值抽检。在IDL里分别读取参考影像和重投影结果同一位置的像元值,打印出来对比。批量抽几个点:
x_pts = [100, 500, 800] y_pts = [100, 500, 800] for i = 0, 2 do begin print, x_pts[i], y_pts[i], $ ' ref=', envi_get_data(fid=ref_fid, dims=[0,0,ref_ns-1,0,ref_nl-1], pos=[0])[ref_ns*y_pts[i]+x_pts[i]], $ ' out=', envi_get_data(fid=out_fid, dims=[0,0,g_out_ns-1,0,g_out_nl-1], pos=[0])[g_out_ns*y_pts[i]+x_pts[i]] endfor不要指望两个值一模一样,毕竟不同传感器的成像时间、光照条件、大气状态都不一样,绝对相等才见鬼。但如果输出结果和参考影像之间存在整体性的、固定方向的平移,那就要排查是不是GLT参数设置的问题。
4. 常见问题与排查技巧实录
4.1 问题速查表:我自己踩过的那些坑
下面这些坑没有一个是凭空想出来的,全部是我在代码里实际遇到过、然后一步步排查过的内容。我做成速查表,方便你对照查看。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 生成的GLT文件里所有值都是-1 | 参考影像和源影像的投影坐标范围完全没有交集;或者源影像本身缺少地图信息但参考影像投影差异过大 | 在ENVI里分别查看两幅影像的头文件信息,确认两者经纬度或投影范围有重叠区域 |
| 重投影结果整体平移了半个像元 | 输出网格的起点与像元中心定义不一致 | 调整源影像或参考影像的起始坐标,尝试xstart加/减半个像元 |
调用georef_glt_doit时报数组维数不匹配 | glt_dims写成了源影像的dims | 改为envi_file_query从GLT文件获取的glt_dims |
| 输出结果全是背景值 | GLT应用了但映射失效;或者背景值设置和有效值区间冲突 | 单独在IDL里打印GLT两个波段的min/max,确认有效值范围覆盖了源影像的0到行数列数 |
| 多波段结果里只有第一个波段正确 | in_pos只写了[0] | 改成indgen(nb)或显式列出全部波段序号 |
| 生成的GLT文件特别大,占用几个GB | GLT默认存成浮点型 | 如果是临时使用,可以改用内存变量,或者处理完成后及时用IDL删除中间文件 |
| 结果出现条带拉花 | 源影像本身存在规则性数据缺失,或者GLT精度不够 | 如果是分类图则检查无效区;如果是连续值则改用双线性或三次卷积;如果源数据问题则先做预处理 |
表里第一行和第三行是我见过最多的报错。第一次跑GLT时,很多人不看参考影像的地图信息,随手选了一景和自己源影像范围毫无交集的参考图,结果GLT生成了,但整张表全是-1,最后重投影出来自然也是背景值。所以我在代码里故意加了一个file_test检查,GLT文件虽然生成了,但内容是否有效还得自己再判读一下。
4.2 几个让我纠结过的细节
GLT中的X/Y方向到底是谁对应谁,这个我一开始也搞反过。GLT第一个波段存的是输入影像的列方向坐标(sample方向),第二个波段存的是行方向坐标(line方向)。如果你在应用GLT后发现结果和参考影像形成90度旋转,那大概率是波段顺序或者行列坐标理解写反了。
另一个细节是GLT里的值到底代表什么。有效值可以从0开始,一直到源影像的行数减1或列数减1,这之间可以带小数,比如135.6这样的值,表示该位置需要进行亚像元插值采样。对应到最近邻时,四舍五入取整;对应到双线性时,周围四个像元加权平均。
还有关于背景值的选择。ENVI里的background关键字默认是0,这个默认值在大多数场合没什么问题,但如果你要后续做影像统计、计算NDVI这类指数,背景0可能让你的统计结果出现异常峰。我习惯切成-9999或者用/use_glt_invalid这类特殊关键字把无效区单独标记,具体关键字看版本。稳妥的通用做法:背景值设成一个源影像中绝对不会出现的异常值。
4.3 性能优化思路:大批量数据处理时的调优
当你处理几十景影像时,GLT生成和应用通常不是瓶颈,磁盘IO才是。我给一个典型流程的建议。
先把GLT生成一次,然后把它放到内存里反复使用。IDL中envi_get_data读取GLT两个波段后,可以存成两个浮点数组。之后应用GLT时,就没必要反复打开GLT文件了,直接对源影像每个波段做数组索引采样。虽然georef_glt_doit也会做类似的事情,但绕开文件层IO会更快。
具体采样可以这样写:
glt_x = envi_get_data(fid=glt_fid, dims=glt_dims, pos=0) glt_y = envi_get_data(fid=glt_fid, dims=glt_dims, pos=1)拿到glt_x和glt_y两个二维数组之后,再配合interpolate或bilinear函数手动采样。这种方法胜在可控:你可以在显存和内存之间做出自己的取舍,也能方便地加入并行处理。不过手动采样代码量更大,日常如果数据量不大,直接用georef_glt_doit就够了,没必要过度工程化。
另外一个优化点是:如果GLT文件需要长期保存,可以考虑把浮点型转成整型。现在很多软件都支持用envi_doit, 'glt_image_doit', /integer或类似选项来生成整型GLT,能大幅压缩文件体积。代价是精度下降:整型GLT只能做到像元级映射,不支持亚像元插值,适合对精度要求不高的快速预处理。我在做快速浏览图时经常用整型GLT,做正式成果图时一定用浮点GLT加双线性采样。
最后再分享一点我的个人体会
这套IDL加ENVI接口的GLT重投影流程,我前前后后跑了快五年,最大的收获就是“别急着套代码,先想清楚你的输出网格到底长什么样”。输出网格的起点、像元大小、投影坐标系,这三个要素决定了整个结果的可用性;代码本身反而只是流水线。每次遇到GLT结果不对的报错,我第一步永远是打开ENVI看参考影像和源影像的头文件,检查投影、范围和分辨率,而不是盯着代码发呆。第二个体会是中间文件一定要留好。GLT生成一次可能只要几十秒,但如果原始数据更新了、或者你需要换个重采样方法,重新生成GLT的成本远低于重新配准控制点。只要GLT还在,换波段、换采样方式、换输出范围,都只是重新套一次表的事。所以我现在做批处理项目时,都会把GLT作为中间产物单独存档,而不是处理完就删。这习惯帮我节省了大量重复计算时间,强烈建议你也试试。