第05章 - Pipeline JSON 与 Stage 模型
5.1 pipeline 到底是什么
把它当成一份执行计划而不是配置文件。pdal pipeline 拿到 JSON 后做的事,和 C++ 里手写 stage 链完全相同:解析出 stage 对象、按 inputs 连边、确定叶子节点、按模式执行。
理解这一点,很多“为什么这样写不行”的疑问就自动消解了:JSON 只是把 StageFactory 的构造过程数据化了,它的规则来自 PipelineReaderJSON::parsePipeline(),不是来自某个 schema。
一条 pipeline 的最小语义只有三条:
- stage 按数组顺序创建;
- 维护一个默认输入列表(default inputs),每创建一个 stage 就把它替换进去;
- 没有显式
inputs的 stage 吃这个默认列表。
5.2 两种顶层形态
[
"input.las",
{ "type": "filters.crop", "bounds": "([0,100],[0,100])" },
"output.laz"
]
和
{
"pipeline": [
"input.las",
{ "type": "filters.crop", "bounds": "([0,100],[0,100])" },
"output.laz"
]
}
完全等价。第二种是 PDAL 1.9 之前的写法,为向后兼容一直保留;新写的 pipeline 一律用裸数组。解析器在 readPipeline 里判断根节点类型,不是数组就报 Pipeline: root element is not a pipeline.。
5.3 字符串元素的推断规则
数组里的字符串不是“文件名”,而是“待推断的 stage”。推断逻辑在 parsePipeline() 里只有几行,但决定了大量实际行为:
type 为空 且 (i == 0 或 i != last) → 按 reader 处理,filename = 该字符串
type 为空 且 i == last → 按 writer 处理
type 以 "readers." 开头 → reader
type 以 "writers." 开头 → writer
其余 → filter
由此得到几条实用结论:
- 第一个字符串一定是 reader,最后一个字符串一定是 writer;
- 中间位置放字符串会被当成 reader(这通常不是你想要的,应显式写
{"type": ...}); - 只有一个元素的 pipeline 会被当成 reader,没有 writer 就不会产生输出文件;
- 显式写
type时,扩展名推断让位于显式声明。
5.3.1 字符串会做 glob 展开
reader 分支里有这么一段:
StringList files = Utils::glob(spec.u8string());
if (files.empty())
files.push_back(spec.u8string());
for (const std::string& path : files) {
...
s = &manager.makeReader(ops);
inputs.push_back(s);
}
也就是说,一个含通配符的字符串会展开成多个 reader,全部塞进默认输入列表:
[
"/data/2024/*.laz",
"merged.laz"
]
这行 pipeline 等价于把这批 LAZ 全部读进来再合并写出,不用显式写 filters.merge——下游 writer 拿到了多个输入。
glob 只在 pipeline 文件里生效。文档明确写了:通过命令行给 pdal pipeline/pdal translate 传文件名时不做通配展开。想要通配就得写进 JSON,或者让 shell 展开后传多个参数。
5.4 Stage 对象:四个保留键
一个 stage 对象的键分两类:保留键由解析器消费,其余全部当作 stage 选项。
| 保留键 | 类型 | 说明 |
|---|---|---|
type |
string | stage 全名,如 filters.smrf;filter 必填 |
filename |
string | 文件路径,可写 URL;reader/writer 用 |
tag |
string | 本 stage 的别名,供 inputs 引用,全局唯一 |
inputs |
string 或 string[] | 上游 stage 的 tag 列表 |
plugin |
string | 加载指定插件(不传给 stage 本身) |
option_file |
string | 从外部文件读取选项 |
user_data |
any | 原样带到序列化输出里,不参与处理 |
其余键按 JSON 类型转成 PDAL 的 Option:
string → 字符串
number → int64 / uint64 / double(按实际类型)
bool → bool
array → 逐个元素加入同名选项(多值选项就靠这个)
object → 整个对象当值传入
null → 空字符串
最后两条容易踩坑。null 被当成空字符串,所以 "where": null 不会清空默认值,只会传一个空串进去;object 类型的值会在 stage 内部被字符串化后重新解析,这也是 bounds 支持 GeoJSON BBOX 与 JSON 对象两种写法的原因。
5.4.1 tag 的命名规则是强制的
tag 不对会直接抛异常,报错信息写得很明确:
| 错误信息 | 触发条件 |
|---|---|
JSON pipeline: tag must be specified as a string. |
tag 不是字符串 |
JSON pipeline: duplicate tag 'x'. |
同一 tag 用了两次 |
JSON pipeline: Invalid tag name 'x'. Must start with letter. Remainder can be letters, digits or underscores. |
命名不合规 |
JSON pipeline: Invalid pipeline: undefined stage tag 'x'. |
inputs 引用了不存在的 tag |
JSON pipeline: 'inputs' tag must be specified as a string or array of strings. |
inputs 类型错 |
JSON pipeline: Inputs not permitted for reader: 'path'. |
给 reader 写了 inputs |
规则:必须以字母开头,后续只能是字母、数字、下划线。las-1 这种带连字符的写法会被拒。这条限制来自 Stage::parseTagName(),不是解析器随手加的。
5.5 inputs 与多分支
默认输入列表的语义要一次性记牢,否则会得到“数据凭空丢失”的结果:
- 当前 stage 是 reader:忽略
inputs(写了就报错),并被追加进默认列表; - 当前 stage 是 filter/writer 且没写
inputs:消费默认列表里的全部 stage; - 当前 stage 是 filter/writer 且写了
inputs:只用指定的那些 tag,默认列表被替换成当前 stage。
“替换”两个字是关键。下面这条 pipeline 是本教程反复引用的范例:两个 reader 分别重投影后再合并。
[
{
"filename": "A.las",
"type": "readers.las",
"spatialreference": "EPSG:26916"
},
{
"type": "filters.reprojection",
"in_srs": "EPSG:26916",
"out_srs": "EPSG:4326",
"tag": "A2"
},
{
"filename": "B.las",
"type": "readers.las",
"tag": "B"
},
{
"type": "filters.merge",
"tag": "merged",
"inputs": ["A2", "B"]
},
{
"type": "writers.gdal",
"filename": "output.tif"
}
]
执行顺序与数据流:
reader A ──▶ reprojection(tag=A2) ──┐
├─▶ merge(inputs=[A2, B]) ──▶ writers.gdal
reader B (tag=B) ───────────────────┘
几个细节值得注意:
- reader
B声明在A2之后,但没有任何连接被自动建立——B 只是被追加到默认输入列表里; merge明确指定inputs,因此只取A2与B,默认列表里可能残留的其他 stage 全被忽略;- 如果省略
merge的inputs,它会吞掉默认列表里的全部内容(当前就是 A2 和 B),结果相同。但一旦 pipeline 更复杂,显式inputs才能保证你连的是想要的那条链。
5.5.1 多叶子节点只是警告,不是错误
如果 pipeline 长出了多个末端(没有人消费的 stage),解析器不会失败,只会在日志里报:
Pipeline has multiple leaf nodes.
Only the first of the following leaf nodes will be run.
<tag 或 stage 名>
...
这条消息常常被忽略,因为它是 LogLevel::Error 级别但不中断执行。后果是:你以为两个分支都跑了,实际只跑了第一个叶子所依赖的那条链。文档也专门说明了这一点——pipeline 可以有多个 writer,但必须顺序串联(Reader → Filter → Writer → Filter → Writer),不能分叉成多个终点。
允许:Reader → Filter → Writer → Writer
允许:Reader → Filter → Writer → Filter → Writer
不允许:
┌─▶ Writer
Reader → Filter ─┤
└─▶ Writer
需要多份输出时,正确做法是写多条 pipeline,或者在同一条里串接多个 writer(前一个 writer 的输出作为后一个的输入)。
5.6 PointView 与 # 占位符
前文说过,一个 stage 可以输出多个 PointView。写侧要接住它们,靠文件名里的 #:
[
"input.las",
{
"type": "filters.splitter",
"length": 1000
},
"tile_#.las"
]
filters.splitter 每个瓦片产出一个 view,writer 为每个 view 写一个文件,# 被替换成递增整数。writers.las 与 writers.bpf 是明确支持这个机制的。
反过来,如果文件名不含 #,所有 view 会被合并成一份输出。这个默认行为很合理,但也会掩盖“本来想分开写”的意图:跑完只看到一个文件,数据却没丢——全在里面。
能用 # 的前提是上游真的产出了多个 view。产出多 view 的典型 stage:
| stage | 怎么产生多 view |
|---|---|
filters.splitter |
按瓦片边长切 |
filters.chipper |
按容量切(默认 5000 点/chip) |
filters.divider |
partition/round_robin/expression 三种模式 |
filters.crop |
给多个 bounds/polygon 就一个区域一个 view |
readers.* |
多个 reader 并行时天然多输入 |
5.7 where 与 where_merge:只处理一部分点
每个 filter 和 writer 都支持 where(来自 doc/stages/filter_opts.md):一个表达式,只有满足条件的点才进入这个 stage,其余点跳过但保留,继续给下游。
[
"input.laz",
{
"type": "filters.assign",
"where": "Classification == 1",
"value": "Classification = 0"
},
"output.laz"
]
where_merge 决定被跳过的点怎么归并:
| 取值 | 行为 |
|---|---|
true |
合并进该 filter 返回的第一个 view;没有返回 view 时单独成 view |
false |
单独成一个 view |
auto(默认) |
只有一个返回 view 且点数未变时合并,否则单独成 view |
auto 的判定条件值得记住:返回单一 view 且点数与执行前一致。很多 filter 会改变点数(过滤类)或产出多个 view(切分类),此时被跳过的点会独立成 view,于是下游写出时可能出现意料之外的多份输出。
5.8 option_file:把选项挪出去
所有 stage 都支持 option_file,把选项放到独立文件里。两种语法都行——JSON 块或命令行风格:
{
"minor_version": 4,
"out_srs": "EPSG_4326"
}
--minor_version=4 --out_srs="EPSG_4326"
典型用法是同一套参数复用:把常用的 writer 参数抽成 las-out.opts,多个 pipeline 引用它。
[
"input.las",
{
"type": "writers.las",
"filename": "output.las",
"option_file": "conf/las-out.opts"
}
]
注意 option_file 里的选项与 stage 内联选项冲突时,内联的优先(选项后加入者覆盖先加入者)。所以把“默认值”放文件里、“这一条的特殊值”写在 JSON 里,是最稳妥的组织方式。
5.9 序列化与元数据:让 pipeline 自证
pipeline 最有价值的副产品是可复现性。三个入口:
| 命令 | 产物 |
|---|---|
pdal translate ... --pipeline out.json |
命令行构造出的等价 pipeline(不执行) |
pdal pipeline in.json --pipeline-serialization out.json |
实际执行过的完整 pipeline |
pdal pipeline in.json --metadata meta.json |
执行元数据(每个 stage 的统计与耗时) |
--pipeline-serialization 与 --pipeline 的区别值得强调:后者是“按我的命令行参数应该长这样”,前者是“实际跑的是这样”,包含 parser 补全的内容。生产环境归档用后者。
writer 侧还有两个把 pipeline 写进数据的选项:
writers.las.pdal_metadata:写两个 VLR,分别存 metadata 与 pipeline 序列化;writers.copc.pipeline:把运行中的 pipeline 以 VLR 写进 COPC。
这两个开关的价值在交付场景:数据文件自己带着处理历史,接收方不需要你的文档也能知道它经历过什么。
5.10 排错表
| 报错 / 现象 | 原因 | 处理 |
|---|---|---|
Pipeline: root element is not a pipeline. |
根节点是对象但没 pipeline 键 |
用裸数组或补上 pipeline 键 |
JSON pipeline: duplicate tag 'x'. |
tag 重复 | 改唯一名 |
JSON pipeline: Invalid tag name 'las-1'. |
含连字符 | 改成 las1 或 las_1 |
JSON pipeline: undefined stage tag 'x'. |
inputs 引用了未定义或后置的 tag |
tag 必须先定义后引用 |
JSON pipeline: Inputs not permitted for reader |
给 reader 写了 inputs |
删掉;reader 只能被追加 |
Pipeline has multiple leaf nodes. |
分叉出多个末端 | 串联 writer,或拆成多条 pipeline |
| 输出少了数据 | 某个 filter 用了 inputs 导致默认列表被替换 |
检查每个 filter 的 inputs |
| 输出只有第一个分支 | 多叶子只跑第一个 | 同上 |
--where 没生效 |
表达式写成赋值形式 | where 只接受布尔表达式 |
| 相对路径找不到 | 基准目录是进程 CWD,不是 JSON 所在目录 | 用绝对路径或在 JSON 同目录执行 |
最后一条是高频问题:pipeline 里的相对路径以进程当前工作目录为基准,与 JSON 文件位置无关。在 /etc/cron.d 里跑一条引用 data/input.las 的 pipeline,实际找的是 /data/input.las。稳妥做法是 JSON 里一律用绝对路径,或脚本里先 cd 到数据目录。
5.11 生产建议
显式写 type,别依赖推断。 推断规则只适合示例和小脚本。生产 pipeline 里每个 stage 都写全 type,reader/writer 都写全 filename。
每个 filter 都显式写 inputs(超过 3 个 stage 时)。 默认输入列表是隐式状态,stage 一多就难追踪。显式连边让数据流在 JSON 里就能读出来。
用 tag 命名关键中间结果。 tag: "ground"、tag: "reproj" 让 inputs: ["ground", "reproj"] 自解释;出问题时日志里也有可读的名字。
把 --pipeline-serialization 的输出入库。 它是“实际执行了什么”的唯一权威记录,比手写 JSON 更可靠。
归档时打开 pdal_metadata / pipeline VLR。 数据自带处理史,尤其适合跨组织交付。
5.12 小结
| 主题 | 关键事实 |
|---|---|
| 顶层形态 | 裸数组;{"pipeline": [...]} 是 1.9 前的兼容写法 |
| 解析入口 | PipelineReaderJSON::parsePipeline(),规则来自源码不是 schema |
| 字符串推断 | 首位或非末位→reader;末位→writer;中间字符串会被当 reader |
| glob | 仅 pipeline 文件内生效,一个通配展开成多个 reader |
| 保留键 | type/filename/tag/inputs/plugin/option_file/user_data |
| tag 规则 | 字母开头,后续字母/数字/下划线;全局唯一;先定义后引用 |
| 默认输入列表 | filter/writer 无 inputs 时消费全部;有 inputs 时替换 |
| 多叶子 | 只警告不报错,只有第一个叶子会被执行 |
| 多视图 | # 占位符逐个写文件;无 # 则合并成一份 |
| 通用选项 | where 表达式 + where_merge(auto 默认) |
| 序列化 | --pipeline(构造的)vs --pipeline-serialization(实跑的) |