☰
遥感影像坐标转换:矢量点如何批量映射到像素坐标
2026/10/20 7:13:01 网站建设 项目流程

做遥感目标检测那段时间,我几乎每天都要和一组坐标较劲。手头是一张带地理参考的正射影像,外加几百个矢量点——机载GPS记录的样地点,或者从某份规划文件里提出来的控制点。需求很直接:这些矢量点配准到影像上之后,它们在影像里到底落在第几行第几列,像素坐标是多少。

一开始我习惯在GIS软件里直接用“位置”工具去读,但点一多就彻底崩了。某个无人机航拍项目里,一张影像对应三千多个样地点,手工逐个读取再核对,两天时间搭进去还不断出错。后来我才意识到,问题的核心不是“在软件里怎么点选”,而是“影像和矢量点之间那套坐标映射关系”到底怎么算。这篇文章就把我沉淀下来的完整思路写清楚:为什么矢量点能映射到像素坐标、怎么用仿射变换批量计算、处理批量数据时的坑,以及验证结果的几种方法。

1. 这个需求到底从哪来:遥感影像、矢量点与像素坐标的三方关系

1.1 典型场景:不是只有测绘才需要像素坐标

先说几个真实场景,大家对号入座。

做目标检测训练集时,标注人员通常面对的是栅格影像,需要把目标物的中心点或角点框出来。但很多时候,样本目标列表是已知的——比如已知某片区域有几十个油罐,每个油罐的中心经纬度都已经记录在案。问题是:这些经纬度落在影像的哪个像素上?手动放点效率太低,还容易放偏。

做无人机巡检时,飞机记录的是GPS点,现场拍到的正射影像却有自己的像素分辨率。飞手想定位某个问题点(比如光伏板上的热点),需要快速知道该点在影像上的像素坐标,才能裁剪出局部图交给算法去识别。

做变化检测、样本点复核时,历史影像上有一批量测点,新影像来了之后要重新定位同名点。这时候根本不可能一个个找,必须用程序把每个点从地理坐标批量换算成像素坐标。

这些任务背后全都是同一个数学模型:影像内部记录了一套仿射变换参数,把像素坐标和地理坐标联系起来;只要把矢量点坐标统一到影像的坐标系下,代入公式,行列号立等可取。

1.2 配准前后的两个世界:地理坐标与像素坐标

在动手之前,先分清楚两个世界。

第一个世界叫地理空间,坐标单位是经纬度或者投影长度单位(米)。矢量点的坐标就生活在这个世界里,比如东经 120.5 度、北纬 31.2 度,或者某个投影坐标系下的东向坐标 500000、北向坐标 4100000。

第二个世界叫像素空间,坐标单位就是“像素行列号”。一幅 4000×3000 的影像,左上角像素的坐标是 (col=0, row=0),右下角是 (col=3999, row=2999)。这个世界里没有经纬度的概念,只有列号向右增大、行号向下增大。

“配准”在本文语境里做两件事:一是让影像带上准确的地理参考信息(也就是常见的 GeoTIFF 所包含的坐标参照框架),二是让矢量点和影像在同一个坐标系下对齐。完成这一步之后,两个世界之间就存在一个确定的数学映射,可以从任意地理坐标算出像素行列号,也可以反着算。

1.3 本文的思路主线

后面内容就围绕一条主线展开:拿到一张带地理参考的影像和一组矢量点,如何批量、准确、可复核地获取每个矢量点的像素坐标。第二步拆解仿射变换参数的数学意义;第三步讲矢量点数据的读取和坐标系统一;第四步给出完整可运行的 Python 代码;第五步介绍验证方法;第六步处理影像无地理参考、旋转影像、裁剪偏移等复杂情况。

2. 配准的技术内核:仿射变换与地理参考信息

2.1 geotransform 六参数到底是什么

GDAL 打开 GeoTIFF 后,调用dataset.GetGeoTransform()会返回一个长度为 6 的元组。这六个数字决定了影像的地理位置、像素大小和旋转关系。很多新手直接忽略它,导致坐标换算永远算不对。

逐个拆解这六个参数:

  • gt[0]: 影像左上角像素的地理 X 坐标(通常经度或投影东向坐标)
  • gt[1]: 一个像素在 X 方向上的物理宽度(东西方向分辨率)
  • gt[2]: 行方向旋转系数,影像未旋转时为 0
  • gt[3]: 影像左上角像素的地理 Y 坐标(通常纬度或投影北向坐标)
  • gt[4]: 列方向旋转系数,影像未旋转时为 0
  • gt[5]: 一个像素在 Y 方向上的物理高度(南北方向分辨率),通常为负值

用一句人话概括:gt[1]代表“向东移动一个像素,X 增加多少”,gt[5]代表“向南移动一个像素,Y 减少多少”。这就是为什么绝大多数正北朝向的影像里,gt[5]是一个负数。

什么时候gt[2]和gt[4]不为零?当影像不是正北朝向、经过旋转时,这两个系数就反映旋转关系。比如无人机影像经过空三定向后不一定是正北的;或者某些影像做过旋转配准操作,参数里会带角度信息。遇到这种影像,只取gt[1]和gt[5]去计算就会出错,必须使用完整公式。

2.2 计算公式与手算示例

已知行列号求地理坐标的正变换公式如下:

X = gt[0] + col * gt[1] + row * gt[2] Y = gt[3] + col * gt[4] + row * gt[5]

已知地理坐标求行列号的逆变换,完整公式为:

col = (gt[2] * (Y - gt[3]) - gt[5] * (X - gt[0])) / (gt[1] * gt[5] - gt[4] * gt[2]) row = (gt[1] * (Y - gt[3]) - gt[4] * (X - gt[0])) / (gt[4] * gt[2] - gt[1] * gt[5])

注意分母和分子的符号要特别小心。当影像未旋转(gt[2] = 0、gt[4] = 0)时,公式可以化简为:

col = (X - gt[0]) / gt[1] row = (Y - gt[3]) / gt[5]

举一个具体例子。假设某影像的gt = (500000, 2, 0, 4100000, 0, -2),意味着左上角地理坐标是东向 500000、北向 4100000,每像素宽度 2 米,高度也是 2 米(向南递减)。有一个矢量点坐标是(500010, 4099998),代入简化公式:

col = (500010 - 500000) / 2 = 5 row = (4099998 - 4100000) / (-2) = 1

也就是说这个点位于影像第 5 列、第 1 行。注意行列号从 0 开始编号,这是 GDAL 和多数栅格库的约定,不要从 1 开始。

2.3 坐标系与投影:最大的一块暗礁

公式本身不难,难的是确保X、Y和影像坐标系完全一致。影像可能是 WGS84 经纬度(x=经度、y=纬度),矢量点却可能是 UTM 投影(单位米),也可能是 CGCS2000 高斯投影。直接把经纬度当米来算,结果完全不可用。

我踩过这个坑。某次做一块稻田区域的样地分析,拿到样例点文件,文档里写着“坐标为投影坐标”。我没有验证就直接套到 UTM 影像上,几百个点全部跑到影像外面。逐个检查数据才发现,文件里存的是经纬度,只是字段名误导了我。从那次之后,我养成了先确认坐标系再动手的习惯。

判断方法很简单:分别打印影像坐标系和矢量点坐标系。GDAL 里用dataset.GetProjection()读取影像的投影信息,fiona 读取矢量后可以通过src.crs拿到坐标系统。两个坐标系不一致时,必须先将矢量点坐标转换到影像坐标系,再计算像素坐标。转换代码下一章给出。

3. 矢量点数据怎么读、怎么统一到影像坐标系

3.1 读取 shapefile 中的点要素

最常用的矢量格式是 shapefile。用 fiona 读取非常清晰,一个循环就能拿到每个点的坐标和属性:

import fiona with fiona.open('points.shp', 'r') as src: for feature in src: geom = feature['geometry'] if geom['type'] == 'Point': x, y = geom['coordinates'][:2] print(x, y, feature['properties'])

这里有两个细节要提醒。第一,shapefile 里可能混有PointZ类型,坐标是三元组(x, y, z),所以取前两个元素比较稳妥。第二,如果遇到MultiPoint,需要循环取出每个子坐标。实际业务数据中常出现“看似是点,实际上是多点集合”的情况,处理时不要偷懒。

如果不用 fiona,也可以用 pyshp。我习惯用 fiona,主要是因为它能直接读取crs信息,方便下一步坐标系判断。

3.2 坐标系不一致时的转换操作

坐标转换优先推荐 pyproj。它的Transformer类可以一次性构建转换器,后续反复调用,效率很高。

from pyproj import Transformer transformer = Transformer.from_crs("EPSG:4326", "EPSG:32650", always_xy=True) x_new, y_new = transformer.transform(x, y)

always_xy=True这个参数非常关键。它强制输入输出顺序始终是(x, y),也就是经度在前、纬度在后。不同库对经纬度顺序的约定不一致,有的默认(lat, lon),有的默认(lon, lat)。一旦顺序搞反,结果偏移上百公里是家常便饭。我建议所有项目里统一加上always_xy=True,省掉大量心智负担。

如果不想引入 pyproj,也可以用 GDAL 自带的 osr:

from osgeo import osr src_srs = osr.SpatialReference() src_srs.ImportFromEPSG(4326) dst_srs = osr.SpatialReference() dst_srs.ImportFromEPSG(32650) coord_trans = osr.CoordinateTransformation(src_srs, dst_srs) x_new, y_new, _ = coord_trans.TransformPoint(x, y)

注意TransformPoint返回三元组(x, y, z),平面转换时取前两个即可。两种方案我都用过,项目里需要快速原型时用 pyproj,老环境里只能靠 GDAL 时就用 osr。

3.3 怎么判断到底要不要转

读取影像坐标系用一行代码:

from osgeo import gdal ds = gdal.Open('image.tif') img_crs_wkt = ds.GetProjection()

读取矢量坐标系用 fiona:

with fiona.open('points.shp', 'r') as src: vec_crs_wkt = src.crs_wkt

拿到两个 WKT 字符串后,直接做字符串比较并不可靠。更稳妥的办法是把它们转成osr.SpatialReference对象,再用IsSame()判断。如果影像和矢量都声明了 EPSG 代码,也可以直接比较 EPSG 数值。

有一个容易忽略的细节:即使两边都写着 WGS84,也可能是不同的实现。比如 WGS84 经纬度和 WGS84 Web Mercator 都带“WGS84”字样,但前者单位是度,后者单位是米。不要看到相同的名字就跳过转换,必须精确比较坐标系定义。

批量处理时的性能问题也得考虑。几百个点逐个转换没有任何压力,但几万个点再用 Python 循环就有点吃力了。这时可以把坐标放进 numpy 数组,直接调用 pyproj 的数组接口:

from pyproj import Transformer import numpy as np xs = np.array([x1, x2, x3]) ys = np.array([y1, y2, y3]) transformer = Transformer.from_crs("EPSG:4326", "EPSG:32650", always_xy=True) new_x, new_y = transformer.transform(xs, ys)

这一步在几十万点的项目里能把处理时间从分钟级降到秒级。

4. 完整实现:把配准后的矢量点批量换算成像素坐标

4.1 GDAL 版本的完整脚本

下面这段是我一直当模板用的脚本,每次只改文件路径和坐标系。代码里包含了地理参考检查、坐标系转换、像素坐标计算和 CSV 输出。

from osgeo import gdal, osr import fiona import csv def calc_pixel_coords(tif_path, shp_path, csv_path): ds = gdal.Open(tif_path) if ds is None: raise RuntimeError("无法打开影像: " + tif_path) gt = ds.GetGeoTransform() if gt == (0, 1, 0, 0, 0, 1): raise RuntimeError("影像没有地理参考信息") width = ds.RasterXSize height = ds.RasterYSize img_srs = osr.SpatialReference() img_srs.ImportFromWkt(ds.GetProjection()) results = [] with fiona.open(shp_path, 'r') as src: vec_srs = osr.SpatialReference() vec_srs.ImportFromWkt(src.crs_wkt) need_transform = not img_srs.IsSame(vec_srs) coord_trans = None if need_transform: coord_trans = osr.CoordinateTransformation(vec_srs, img_srs) for feat in src: geom = feat['geometry'] if geom['type'] in ('Point', 'PointZ'): x, y = geom['coordinates'][:2] elif geom['type'] == 'MultiPoint': coords_list = geom['coordinates'] if not coords_list: continue x, y = coords_list[0][:2] else: continue if need_transform: x, y, _ = coord_trans.TransformPoint(x, y) col = (gt[2] * (y - gt[3]) - gt[5] * (x - gt[0])) / (gt[1] * gt[5] - gt[4] * gt[2]) row = (gt[1] * (y - gt[3]) - gt[4] * (x - gt[0])) / (gt[4] * gt[2] - gt[1] * gt[5]) if 0 <= col < width and 0 <= row < height: results.append({ 'col': round(col, 2), 'row': round(row, 2), 'x': x, 'y': y, 'properties': feat['properties'] }) with open(csv_path, 'w', newline='', encoding='utf-8') as f: fieldnames = ['col', 'row', 'x', 'y'] + list(results[0]['properties'].keys()) if results else ['col', 'row', 'x', 'y'] writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() for r in results: row_out = {'col': r['col'], 'row': r['row'], 'x': r['x'], 'y': r['y']} row_out.update(r['properties']) writer.writerow(row_out) print(f"共处理 {len(results)} 个落入影像范围内的点")

几个设计细节值得解释。第一,地理参考检查:gt等于默认值(0, 1, 0, 0, 0, 1)说明影像没有真实地理信息,后面做任何计算都没意义。第二,行列号用了完整仿射公式,即使影像旋转也不会出错。第三,range check用0 <= col < width而不是0 <= col <= width,因为像素编号从 0 开始,最后一行/列的编号是width-1、height-1。

4.2 rasterio 的更优雅写法

如果你的环境允许用 rasterio,代码可以更简洁。rasterio 把仿射变换封装成了对象,直接用逆变换就能得到像素坐标:

import rasterio with rasterio.open('image.tif') as src: transform = src.transform width = src.width height = src.height for x, y in points_geo: col, row = ~transform * (x, y) if 0 <= col < width and 0 <= row < height: print(col, row)

这里的~transform是仿射矩阵的逆。注意 rasterio 返回的col、row默认是浮点数,是否取整取决于用途。两种方案我都在生产环境用过,GDAL 版本更适合没有第三方依赖的服务器环境,rasterio 版本日常开发效率更高。

4.3 输出结果的组织方式

输出 CSV 时,建议至少包含这几列:原始地理坐标x、y,像素坐标col、row,以及矢量点的属性字段。这样后续可以按属性追溯,也可以把像素坐标带回 GIS 复核。

特别注意,列名最好写成col、row,不要叫x、y,否则两个坐标体系混在同一份表里,非常容易看混。如果做了坐标转换,还应该加一列src_crs记录原始坐标系,防止文件流转几轮之后搞不清数据来源。

如果目标是深度学习标注,通常需要整数像素坐标,直接取整即可。如果做亚像素定位,比如控制点量测、角点提取,就要保留浮点数字。我建议在 CSV 里保留两位小数,后续处理时再按需取整,避免信息丢失。

5. 结果验证与高频排错

5.1 验证方法一:反算回地理坐标

正确性验证的黄金方法,是把算出的像素坐标代入正变换公式,反推地理坐标,再和原始坐标对比。

x_check = gt[0] + col * gt[1] + row * gt[2] y_check = gt[3] + col * gt[4] + row * gt[5]

因为正变换本身就是仿射变换,只要公式正确,反算结果应当和原坐标完全一致。出现 1e-6 级别的误差属于浮点精度,完全可以忽略。如果差出几十米甚至上千米,那一定是坐标系没统一,或者公式方向写反了。

5.2 验证方法二:把点画回影像检查

更直观的办法是把像素坐标画到影像上,目视检查点是否落在预期的地物上。

用 Pillow 快速绘制:

from PIL import Image, ImageDraw img = Image.open('preview.png') # 事先转好的 8bit 预览图 draw = ImageDraw.Draw(img) for col, row in pixel_coords: draw.ellipse([col - 3, row - 3, col + 3, row + 3], outline='red') img.save('check.png')

注意 TIFF 可能是 16bit 或浮点型,PIL 直接打开可能异常。我的做法是先用 GDAL 生成一张缩小的 8bit 预览图,再做可视化检查。检查时重点看点是否落在正确地物上,比如样地点是否落在树冠中心、问题点是否落在楼顶边缘。这个方法最能暴露坐标系错误和公式写反的问题。

5.3 高频问题排查对照表

下面这张表汇总了我这几年被问得最多的问题和排查路径:

现象常见原因排查思路
所有点都跑到影像外坐标系不一致,或经纬度顺序颠倒先打印影像和矢量的 crs,再确认x是经度还是纬度
点整体偏移几个像素影像被裁剪过,geotransform仍是旧参数重新读取裁剪后影像的GetGeoTransform()
GetGeoTransform()返回默认值影像根本没有地理参考先做几何配准或控制点拟合,再计算
行列号出现负值或超过宽高点确实不在影像范围内,或gt[5]方向搞反确认gt[5]是否为负值,确认像素边界
旋转影像上点位置不对用了简化公式,忽略gt[2]和gt[4]改用完整仿射公式或 rasterio 逆变换

还有一个经典问题:到底要不要减 0.5。GDAL 的 geotransform 中,(gt[0], gt[3])是左上角像素左上角的坐标,而不是像素中心的坐标。所以当你用整数行列号反算地理坐标时,得到的其实是像素左上角的位置;而很多 GIS 软件展示坐标时用的是像素中心。这个差异最多导致 0.5 个像素的偏移。做目标检测标注时通常无所谓,但做高精度控制点选择时就要在代码里统一约定。

6. 更复杂的现实情况与对策

6.1 影像没有地理参考怎么办

现实里经常遇到没地理参考的影像:从地图软件截图保存的,或者某些老旧扫描图。这时GetGeoTransform()返回默认值,直接用公式算不了任何结果。

处理思路是:用至少两个(最好四个以上)均匀分布的地面控制点做仿射拟合。具体做法是在 GIS 软件里手工选择若干同名点,把像素坐标和地面坐标成对记录下来,再通过最小二乘拟合出六个仿射参数。numpy 的lstsq函数可以完成:

import numpy as np # A 矩阵每一行是 [col, row, 1] A = np.array([ [c1, r1, 1], [c2, r2, 1], [c3, r3, 1], [c4, r4, 1], ], dtype=float) bx = np.array([x1, x2, x3, x4], dtype=float) by = np.array([y1, y2, y3, y4], dtype=float) params_x, _, _, _ = np.linalg.lstsq(A, bx, rcond=None) params_y, _, _, _ = np.linalg.lstsq(A, by, rcond=None)

拟合完成后,任意像素点(col, row)的地理坐标就是:

x = params_x[0] * col + params_x[1] * row + params_x[2] y = params_y[0] * col + params_y[1] * row + params_y[2]

要由地理坐标反求像素坐标,解一个二元线性方程组即可。如果只是临时算几个点,直接在 QGIS 里用坐标提取工具也行;要批量处理,还是写成脚本更靠谱。

6.2 旋转影像的处理

无人机正射影像和某些经过定向的影像往往带旋转角,此时gt[2]、gt[4]不再是零。网上很多代码只写简化公式,遇到旋转影像就开始出错。正确做法是使用本节前面给出的完整公式,或者直接调用 rasterio 的~transform,后者内部自动处理旋转。

如果一定要手写通用解法,推荐直接用 numpy 解线性方程。核心逻辑是把仿射矩阵看作一个 2×2 矩阵,加上平移向量:

A = np.array([[gt[1], gt[4]], [gt[2], gt[5]]]) b = np.array([x - gt[0], y - gt[3]]) col_row = np.linalg.solve(A, b) col, row = col_row[0], col_row[1]

这里要注意矩阵元素的排列顺序。GDAL 的 geotransform 在 x 方向上的系数是gt[1]和gt[4],在 y 方向上的系数是gt[2]和gt[5],组合成矩阵时不要弄反。

6.3 裁剪影像后的坐标漂移

影像裁剪是高频操作。裁剪后会生成新影像,左上角坐标、像素宽高都可能发生变化。如果沿用旧影像的geotransform去计算像素坐标,所有点都会偏移,而且偏移量还不固定。

我自己在这个问题上栽过跟头。一个五万多行的批量处理脚本,最初把gt放在函数外当全局变量,后来又对同一区域反复裁剪子图,所有结果套用同一套参数。排查时对着输出数据看了很久才发现是缓存惹的祸,改成了每次处理都重新读取当前影像的GetGeoTransform(),问题立刻消失。

原则很简单:每次处理哪个影像文件,就用哪个影像文件的参数。不要缓存,不要想当然,尤其不要假设同一目录下的不同子图参数相同。

6.4 取整策略与亚像素精度

最后说像素坐标的精度取舍。数据标注场景里,边框中心点取整就够了。亚像素定位场景,比如控制点量测、多时相影像配准残差统计,就建议保留两位小数甚至更多。

一个值得养成的习惯:输出文件里保留原始地理坐标,不要只存像素坐标。这样任何人拿到这份文件都能重新换算,不会因为坐标系约定不同而产生歧义。如果对精度有较高要求,像素坐标建议用float64存储,避免大范围坐标下的累积误差。

再提醒一次边界判断:影像宽度是width,但列号范围是0到width-1。很多新手写成0 <= col <= width,结果最后一列的点反复越界。做 range check 时务必用0 <= col < width和0 <= row < height。

我自己的经验是:这套流程里最容易出问题的从来不是数学公式,而是坐标系一致性。每次处理前先花一分钟打印影像和矢量的 crs,确认两侧坐标系,能省下后面几个小时的排查时间。配准完成后,再花五分钟用画点回影像的方式目视检查一遍,这比任何理论推导都更能让人放心。如果你也经常和“点套影像”这类活打交道,建议把这套逻辑沉淀成一个小工具,下次直接把文件路径扔进去就能拿到结果。

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

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

立即咨询