第11章 - 坐标参考系统与高程基准
两批点云叠在一起对不上,X 差了几个数量级;或者叠加得很整齐,但 DEM 算出来的高程整体偏 30 米。前者是水平坐标参考系统(CRS)不匹配,后者是垂直基准不匹配。点云没有底图可以目视校验,一个 SRS 标错要等到土方量、坡度、断面全部算完才会暴露,代价比矢量数据高得多。
本章讲三件事:PDAL 内部如何表示和传递 CRS(SpatialReference 与 PointTable 的规则);LAS/LAZ 文件里的 SRS 到底从哪些 VLR 读出来、按什么优先级;重投影与高程基准转换时,哪些默认行为会让结果”看起来成功、实际错了”。文中命令与报错均来自 PDAL 2.10.0(本机 git-versioneb9126)实测。
11.1 点云里 SRS 的两个分量
11.1.1 水平 CRS 只是入口
水平 CRS 决定 X/Y 的地球解释。常见两种失误:把投影坐标(单位米、坐标量级 10⁵–10⁷)当成经纬度写进 EPSG:4326;或者把 UTM 33N 的点标成 UTM 32N,横向错开约 500 km 且在局部范围内变形平滑,很难看出来。
PDAL 的做法是把 SRS 作为 Stage 的输出状态之一,而不是附在点坐标上的元数据。一个 stage 处理完 PointView,该 view 会继承 stage 的 SRS(如果有)。这意味着 pipeline 中间某一段把 SRS 改错,后面的 stage 会一路沿用错误 SRS,直到写出文件。
11.1.2 垂直分量才是大头
点云 Z 通常被直接当”高程”用来做 DEM、等高线、土方。但 Z 至少有三种含义:椭球高 h(相对参考椭球,WGS84 下与正高差 −100…+100 m 量级)、正高 H(相对大地水准面)、局部假定高程(施工坐标,可能任意偏移)。把椭球高当正高用,在中国东部、欧洲、北美都有几十米级系统偏差。
PDAL 没有单独的”垂直基准”stage。垂直信息只能靠两处表达:SRS 本身是复合 CRS(例如 EPSG:4326+5773 表示 WGS84 水平 + EGM96 高程),或者交给 PROJ 的 pipeline/网格参数。第 11.5 节会用一个实测例子说明:复合 CRS 写对了,垂直分量也可能被静默忽略。
11.1.3 SRS 在 pipeline 中的流转
reader (LAS VLR / SRS 选项)
│ SpatialReference
▼
Stage::setSpatialReference ──► PointView 继承 stage 的 SRS
│
├─ filters.reprojection 改 X/Y/Z,并把 stage SRS 改成 out_srs
├─ filters.ferry 只搬维度,不动 SRS
├─ filters.assign 只改属性,不动 SRS
▼
writer (a_srs 优先,否则用输入 view 的 SRS 写 VLR)
图中的规则不是约定,而是实现:LasWriter::prepOutput() 里就是 m_srs = getSpatialReference().empty() ? srs : getSpatialReference()——writer 自己设了 a_srs 就用它,否则用上游 view 的 SRS。
11.2 SpatialReference 的解析规则
11.2.1 字符串怎么变成 SRS
pdal::SpatialReference::set() 对输入分三路处理:
| 输入形态 | 处理方式 | 说明 |
|---|---|---|
| WKT2 字符串 | 直接保存 | 内部同时保留 WKT2 |
| WKT1 字符串 | 用 GDAL 转 WKT2_2018 | 保留原始 WKT1,另存 WKT2 |
| 其他字符串 | GDAL OGRSpatialReference::SetFromUserInput |
EPSG:xxxx、PROJ 串、URN 等都走这里 |
第三种是绝大多数情况。解析失败抛出的错误长这样:
PDAL: Could not import coordinate system 'EPSG:99999': PROJ: proj_create_from_database: crs not found
注意它来自 GDAL 的 SetFromUserInput,所以 GDAL/PROJ 能认的写法都能用;反过来,PROJ 数据库里没有的 EPSG 代码一定失败,和 PDAL 版本无关。
11.2.2 一个点表只能有一个”确定”的 SRS
SpatialReference.hpp 的类注释写得很清楚:点表跟踪当前处理中的 view 的 SRS;如果一个点表里有多个不同的 SRS,PointTable::spatialReference() 返回空 SRS,spatialReferenceUnique() 返回 false。
这直接决定了一个高频故障:用 filters.merge 合并两个 SRS 不同的文件,merge 后点表 SRS 变空,紧接着的 filters.reprojection 要么报错,要么被迫用 in_srs 强行指定(那一半数据必然错)。正确做法是先逐文件重投影到同一 out_srs,再合并。
11.2.3 查看一个文件真实的 SRS
pdal info --summary 会输出 metadata.srs 子树,这是核对 SRS 最省事的方式:
pdal info utm33.las --summary
关键字段(PDAL 2.10.0 实测):
"metadata": {
"srs": {
"horizontal": "PROJCS[\"WGS 84 / UTM zone 33N\", ...]",
"vertical": "",
"compoundwkt": "PROJCS[\"WGS 84 / UTM zone 33N\", ...]",
"proj4": "+proj=utm +zone=33 +datum=WGS84 +units=m +no_defs",
"isgeographic": false,
"isgeocentric": false,
"units": { "horizontal": "metre", "vertical": "" },
"json": { "type": "ProjectedCRS", "id": { "authority": "EPSG", "code": 32633 }, ... }
},
"spatialreference": "PROJCS[\"WGS 84 / UTM zone 33N\", ...]",
"comp_spatialreference": "PROJCS[...]"
}
判读规则:vertical 为空字符串就说明这个文件没有垂直基准声明,Z 的来源是未知的(多半是椭球高或仪器高)。units.vertical 同理。json 是 PROJJSON,比 WKT 好解析,做批量 SRS 审计时优先用它。
11.3 LAS/LAZ 里的 SRS 从哪读
11.3.1 三套 VLR 与优先级
LAS 文件里 SRS 可以存在三套地方:GeoTIFF keys(LASF_Projection 下的 34735/34736/34737 号 VLR)、WKT1(34735 之外的 WKT record,LAS 1.4 强制)、以及 PDAL 专用的 WKT2/PROJJSON VLR。io/private/las/Srs.cpp 的 Srs::init() 规定默认顺序:
| 文件特征 | 默认尝试顺序 |
|---|---|
mustUseWkt() 为真 |
WKT2 → PROJJSON → WKT1 |
| 否则 | WKT2 → PROJJSON → GeoTIFF |
mustUseWkt() 的定义在 io/private/las/Header.hpp:WKT global-encoding 位被置且 minor version ≥ 4,或点格式 > 5。也就是说 LAS 1.2 + 点格式 3 的文件走 GeoTIFF 兜底,LAS 1.4 走 WKT 兜底。
先命中的赢,后续不再尝试。这在混用软件产出的文件上会出问题:某些工具同时写了 GeoTIFF 和 WKT,两者不一致时 PDAL 只取一个,且不报错(Debug 日志里只有一句 “File contains both WKT and GeoTiff VLRs which is disallowed.”)。
11.3.2 用 srs_vlr_order 强制选择
readers.las 和 readers.copc 都有 srs_vlr_order 选项,接受逗号分隔的 wkt2,wkt1,projjson,geotiff。非法值直接报错:
PDAL: readers.las: Invalid SRS type 'wkt3'. Must be one of 'wkt1', 'geotiff', 'projjson', 'wkt2'.
如果指定了顺序但一个有效 VLR 都没找到,只有一条 Warning:'srs_vlr_order' specified but no valid VLR was found.,pipeline 继续跑,只是数据没有 SRS。批量处理时这类 warning 很容易被日志噪音淹没,建议把 --loglevel 调到 info 并抓关键词。
11.3.3 写出时如何决定 VLR
writers.las 的行为可以归结成三条:
a_srs非空 → 用a_srs(initialize()里setSpatialReference(aSrs));- 否则用上游 view 的 SRS;
- LAS 1.4 输出总是置 WKT global-encoding 位并写 WKT1 记录;
enhanced_srs_vlrs打开(默认false)时额外写 PDAL 私有 WKT2 与 PROJJSON VLR。
还有一个容易误解的点:forward 不转发 LASF_Projection 容器 VLR。源码注释写得很明确——这些 VLR 由 writer 自己重建,所以你不能靠 forward="vlr" 把源文件的 GeoTIFF keys 原样搬到输出。
最小可跑示例,把一个无 SRS 的合成文件标定并转成 LAS 1.4:
[
{
"type": "readers.faux",
"filename": "faux",
"count": 2000,
"mode": "Normal",
"mean_x": 400250, "mean_y": 5700250, "mean_z": 35,
"stdev_x": 80, "stdev_y": 80, "stdev_z": 10,
"override_srs": "EPSG:32633"
},
{
"type": "writers.las",
"filename": "utm33.las",
"minor_version": 4,
"dataformat_id": 7,
"scale_x": 0.01, "scale_y": 0.01, "scale_z": 0.01,
"offset_x": "auto", "offset_y": "auto", "offset_z": "auto",
"enhanced_srs_vlrs": true
}
]
readers.faux 不是玩具才有用:写回归测试时它是唯一不依赖外部数据的选择。上面 mode 只接受 Ramp、Uniform、Normal、Grid 四种;写错会得到 Option 'seed' not supported with mode 'RampUniformNormalGridInvalid'.——报错里那串枚举就是源码里的全部合法值。
11.4 filters.reprojection 的真实行为
11.4.1 机制与必填项
filters.reprojection 用 GDAL 的 OGRCoordinateTransformation 逐点变换 X/Y/Z,旧坐标被直接替换。要保留原坐标,先用 filters.ferry 复制维度:
[
"utm33.las",
{ "type": "filters.ferry", "dimensions": "X=>Xutm, Y=>Yutm" },
{ "type": "filters.reprojection", "out_srs": "EPSG:4326" },
{ "type": "writers.las", "filename": "wgs84.las",
"scale_x": 0.0000001, "scale_y": 0.0000001, "scale_z": 0.01,
"offset_x": "auto", "offset_y": "auto", "offset_z": "auto" }
]
参数(源码 filters/ReprojectionFilter.cpp 的 addArgs()):
| 参数 | 类型 | 默认值 | 作用 |
|---|---|---|---|
out_srs |
string | 无(位置参数,必填) | 输出 SRS |
in_srs |
string | 空 | 输入 SRS;空则从输入 view 推断 |
in_axis_ordering |
数组 | 空 | 覆盖输入轴序,如 "2,1" |
out_axis_ordering |
数组 | 空 | 覆盖输出轴序 |
in_coord_epoch / out_coord_epoch |
double | 0 | 坐标历元;非 0 且 GDAL ≥ 3.4 才生效 |
error_on_failure |
bool | false |
任一点失败是否抛异常 |
11.4.2 输入没 SRS 时的报错
如果输入 view 没有 SRS,又没有给 in_srs,报错文本是固定的(实测):
PDAL: filters.reprojection: source data has no spatial reference and none is specified with the 'in_srs' option.
修法按优先级:最好的做法是源头修正(reader 用 override_srs 或 default_srs 声明真实 SRS);其次是 pipeline 里显式给 in_srs;两者都别做”猜一个”——猜错的投影不会有任何提示。
override_srs 与 default_srs 是所有 reader 的通用选项(doc/stages/reader_opts.md),二者不能同时使用:前者无条件覆盖文件里的 SRS,后者只在文件没有 SRS 时补上。
11.4.3 坐标精度:默认 scale 会把地理坐标压扁
writers.las 的默认 scale_x/y/z 都是 0.01。这在投影坐标(米)下是 1 cm,在地理坐标(度)下是 0.01° ≈ 1.1 km。实测把一个 2000 点的 UTM 33N 文件转到 EPSG:4326 且不指定 scale:
pdal info wgs84_defaultscale.las --summary
# "minx": 13.56, "maxx": 13.57,
# "miny": 51.44, "maxy": 51.45,
# "scale_x": 0.01, "scale_y": 0.01,
原始范围在 500 m × 500 m 量级,转成度后只剩 0.01° 的格网——所有点被量化到几十个格子上,几何信息基本丢光,而 pipeline 不报任何错误。转到地理坐标时 scale_x/scale_y 应给 0.0000001(约 1 cm)。
11.4.4 轴序:PDAL 默认与传统 GIS 一致
GDAL 3 起,WKT 里的轴序会被严格执行,导致 EPSG:4326 按 lat/lon 而非 lon/lat 解释。PDAL 在 SrsTransform::set() 里对源和目标都调用 SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER),所以默认就是 X=经度、Y=纬度,不需要像调 GDAL 命令行那样额外加 -s_srs 轴序翻转。
只有在数据本身存的就是 lat/lon 时,才需要 in_axis_ordering:
{ "type": "filters.reprojection", "in_srs": "EPSG:4326", "out_srs": "EPSG:32633",
"in_axis_ordering": ["2", "1"] }
轴序向量的三个校验都来自源码:元素必须能转成整数(否则 Unable to convert axis ordering to integer)、长度 ≤ 3、排序后必须是 1,2 或 1,2,3。
11.4.5 失败点会被静默丢弃
error_on_failure 默认 false。逐点变换失败(超出投影有效范围、反投影无解)时,processOne() 返回 false,该点不进入输出 view,pipeline 成功退出。表现是输出点数比输入少,且 summary 里只有 count 一个数字能看出来。
生产 pipeline 里我建议一律打开:
{ "type": "filters.reprojection", "out_srs": "EPSG:4526", "error_on_failure": true }
打开后的错误带坐标,便于定位脏数据:
PDAL: filters.reprojection: Couldn't reproject point with X/Y/Z coordinates of (nan, nan, nan).
11.5 高程基准:PDAL 不做转换,PROJ 做
11.5.1 没有 vertical datum stage
PDAL 的阶段里没有 filters.verticaldatum 之类的东西。垂直基准转换只有两条路:
- 复合 CRS:
out_srs写成EPSG:4326+5773(EGM96)或国家复合代码(如丹麦EPSG:7416、中国 2000 国家高程基准对应的地方代码),由 PROJ 查库决定变换; - 显式 pipeline:
filters.projpipeline的coord_op直接给 PROJ 串,例如+proj=pipeline ... +proj=vgridshift +grids=egm96_15.tif,或+proj=affine +zoff=100。
filters.projpipeline 的三个参数:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
coord_op |
string | 无 | PROJ 串 / WKT2 CoordinateOperation / urn:ogc:def:coordinateOperation:EPSG::XXXX |
reverse_transfo |
bool | false |
反向执行该变换 |
out_srs |
string | 不设置 | 只用于写文件的 SRS 声明,不做一致性校验 |
注意 out_srs 那一行:文档明确说”No check is done to ensure the compliance”。你可以把一个米制平移写成 +proj=affine +zoff=-32.5,同时声明 out_srs 是某个正高复合 CRS,PDAL 照写不误。这既是灵活性,也是最容易产出”元数据撒谎”文件的入口。
该 stage 依赖 GDAL ≥ 3.0,2.x 的 GDAL 环境下二进制里根本没有这个 driver。
11.5.2 一个代价很高的实测
环境:PROJ 9.8.1(OSGeo4W),PROJ_NETWORK=OFF,本机没有 EGM96 网格。把 UTM 33N 的椭球高数据转成 EPSG:4326+5773:
import pyproj
from pyproj import CRS, Transformer
import pyproj.network
pyproj.network.set_network_enabled(False)
src = CRS.from_epsg(32633)
dst = CRS.from_user_input("EPSG:4326+5773") # WGS84 + EGM96 height
t = Transformer.from_crs(src, dst, always_xy=True)
print(t.description)
print(t.transform(400000.0, 5700000.0, 50.0))
输出:
Inverse of UTM zone 33N + Inverse of Null geographic offset from WGS 84 to WGS 84 (with axis order normalized for visualization)
(13.561123205604062, 51.44235231637489, 50.0)
变换描述里的 Null geographic offset 是关键:PROJ 找到了复合 CRS 的定义,但缺少 EGM96 网格文件,于是垂直分量退化为”空偏移”,Z 原封不动返回 50.0。整个过程没有异常、没有 warning,pipeline 会正常写出一个自称是 EGM96 正高、实际是椭球高的文件。
判断方法只有两个:看 projinfo 给出的 pipeline 里有没有 vgridshift 步骤,或看 pdal info --summary 的 metadata.srs.vertical 是否非空(非空只证明声明了垂直分量,不证明网格存在)。
# 看 PROJ 实际会走哪条 pipeline
projinfo -s EPSG:32633 -t EPSG:4326+5773 --spatial-test intersects
# 看 PROJ 的数据目录和网格搜索路径
projinfo --searchpaths
11.5.3 网格文件的部署
PROJ 9 之后网格是 GeoTIFF(旧格式 .gtx 仍被支持)。三种部署方式:
| 方式 | 做法 | 适用 |
|---|---|---|
| 系统 PROJ 数据目录 | 把 .tif 放进 PROJ_DATA(旧环境是 PROJ_LIB)目录 |
离线服务器,一劳永逸 |
| 环境变量网络下载 | PROJ_NETWORK=ON |
开发机;PROJ 从 CDN 拉缺失网格 |
| 显式指定文件 | +geoidgrids=/data/geoids/dvr90.gtx 或 +grids=egm96_15.tif |
单次任务、基准不在 PROJ 库里 |
PDAL 仓库自带的 doc/tutorial/nz_reproject.json 与 grid-shift.md 用的就是第三种:把新西兰本地高程基准的 GTX 文件写进 filters.reprojection 的 out_srs 字符串,再在 writers.las 里用 a_srs 声明最终复合代码,否则 PDAL 会把垂直基准写成 “Unnamed Vertical Datum”(文档原话)。这一条值得照抄——先转换、再声明,两步分开做。
11.6 排错清单
| 现象 / 报错 | 原因 | 修法 |
|---|---|---|
source data has no spatial reference and none is specified with the 'in_srs' option. |
输入无 SRS,或 merge 后 SRS 不唯一 | reader 加 override_srs/default_srs,或显式给 in_srs |
Could not import coordinate system 'X': ... |
EPSG 拼错、PROJ 库里没有、缺 +init=epsg:xxxx 兼容层 |
换 WKT2 或核对 PROJ 数据;projinfo 先验证 |
Invalid SRS type 'wkt3'. Must be one of 'wkt1', 'geotiff', 'projjson', 'wkt2'. |
srs_vlr_order 值非法 |
只用这四个词,逗号分隔 |
'srs_vlr_order' specified but no valid VLR was found.(Warning) |
文件里确实没有该类型的 SRS VLR | 关掉该选项,改用 a_srs/default_srs |
| 输出点数少于输入 | error_on_failure=false 丢弃失败点 |
设 true,用报错定位脏点 |
| 地理坐标输出后几何被压扁 | 沿用默认 scale_x/y=0.01(约 1.1 km) |
显式 0.0000001 |
| 叠加后整体偏 20–50 m | 椭球高当正高用,或反之 | 用复合 CRS 或 projpipeline 转换,并用 projinfo 验证 vgridshift 存在 |
| 输出的 LAS 在别的软件里显示 “Unnamed Vertical Datum” | 复合 CRS 的垂直部分没有正式代码 | 转换后显式写 a_srs 为正式复合 EPSG 代码 |
11.7 生产建议
先审计再处理。 一批数据入库前跑一遍 pdal info --summary,把 metadata.srs.json.id(PROJJSON 里的 authority/code)和 units.vertical 抽出来列表。同一次任务里出现两种水平代码就直接停下,不要用 in_srs 硬转——那等于把错配写进结果。
中间结果保持投影坐标系。 米制坐标下 1 cm 精度只需 scale 0.01,地理坐标下需要 1e-7;而且面积、坡度、距离算法在投影坐标上才成立。只有最终交付或入库 PostGIS geography 时才转经纬度。
垂直基准单独作为一条处理链。 不要把”水平重投影”和”椭球高→正高”塞进同一个 stage 配置里;先确认网格文件到位(projinfo 看得到 vgridshift),再做垂直转换,最后用 a_srs 声明结果。三者分开,出错时能定位到具体一步。
给 reprojection 打开 error_on_failure。 这是唯一能让”丢点”变成”报错”的开关,成本是失败即中断——批处理脚本把它包在 try/except 里逐个文件重试即可。
LAS 1.4 优先。 1.4 强制 WKT VLR,能表达复合 CRS 和三维基准;1.2 走 GeoTIFF keys,很多复合信息根本存不下。代价是文件头和每个点的结构都比 1.2 大,但对需要 SRS 正确性的工程可以接受。
11.8 本章小结
| 要点 | 结论 |
|---|---|
| SRS 表示 | SpatialReference 内部以 WKT2 为主;非 WKT 输入走 GDAL SetFromUserInput |
| 点表约束 | 多个 SRS 混合时点表 SRS 为空,后续 reprojection 必须显式 in_srs |
| LAS 读取顺序 | mustUseWkt() 决定 WKT1 兜底还是 GeoTIFF 兜底,可用 srs_vlr_order 覆盖 |
| 写出行为 | a_srs 优先,其次上游 SRS;LAS 1.4 总写 WKT VLR |
| 重投影默认值 | error_on_failure=false 会静默丢点;轴序默认传统 GIS 顺序 |
| 精度陷阱 | 默认 scale 0.01 在地理坐标下约 1.1 km |
| 垂直基准 | PDAL 不转换,靠复合 CRS 或 filters.projpipeline;缺网格时 PROJ 静默走 Null offset |
| 验证手段 | pdal info --summary 看 metadata.srs;projinfo 看实际 pipeline 是否含 vgridshift |