第13章 - C++ 开发与自定义 Stage
filters.expression 写不出的逻辑、Python 绑定里跑一百万个点太慢的那一段、公司内部特有的传感器格式——这三类需求最后都会落到同一个出口:写一个 C++ stage。PDAL 的扩展模型很轻:一个继承自 Filter(或 Reader/Writer)的类、一个描述结构体、一行注册宏、一个按命名约定产出的动态库,放进 PDAL_DRIVER_PATH 就能被 pdal pipeline 当成原生 stage 使用。
代价是 C++ 的门槛和 ABI 约束。本章按”先跑通、再讲规则、最后讲性能”的顺序:给出一份能编译、能加载、能被 pipeline 调用的最小 filter;讲清插件命名与搜索路径这套规则(90% 的”插件加载不上”都出在这里);再讨论流式实现与类型系统的坑。代码以 PDAL 2.10.0 的 API 为准。
13.1 什么时候值得写 C++ stage
先给判断标准,避免”为了用 C++ 而用 C++”:
| 需求 | 推荐方案 |
|---|---|
| 按属性条件筛点、改属性 | filters.expression / filters.assign,纯 JSON |
| 一次性数据清洗、调用外部服务 | filters.python(原型)→ 稳定后改写 |
| 每点计算量小但点数上亿 | C++ streamable filter |
| 新的文件格式读写 | C++ reader/writer + 插件 |
| 需要访问 PDAL 内部类型(PointView、KD 索引) | C++ filter |
| 只是编排已有 stage | Python 绑定或 JSON pipeline |
换算成本:一个 C++ filter 从 0 到能在生产 pipeline 里用,通常是一天(含构建、调试、CI),之后每次 PDAL 大版本升级都要重新编译。如果逻辑能用 filters.expression 表达,别写 C++。
13.2 构建环境
13.2.1 依赖
写外部插件只需要 PDAL 的开发文件(头文件 + PDALConfig.cmake),不需要 PDAL 源码树。用官方二进制安装包(conda、OSGeo4W、vcpkg)时这些文件都在;自己编译 PDAL 时 cmake --install 会一并安装。
插件必须和 PDAL 主库用同一个编译器 ABI(MSVC 的 _ITERATOR_DEBUG_LEVEL、GCC 的 _GLIBCXX_USE_CXX11_ABI 都要一致)。这就是为什么”conda 里装 PDAL、系统里用 MSVC 编插件”经常在加载时崩:符号对不上。最稳的做法是在同一个 conda 环境里装 pdal + cxx-compiler,用它编译。
13.2.2 最小 CMakeLists
PDAL 官方示例 examples/writing-filter/CMakeLists.txt 只有 11 行,可以直接照抄:
// MyFilter.hpp
#pragma once
#include <pdal/pdal_internal.hpp>
#include <pdal/Filter.hpp>
namespace pdal
{
class MyFilter : public Filter
{
public:
MyFilter() : Filter() {}
std::string getName() const;
private:
double m_value;
Dimension::Id m_myDimension;
virtual void addDimensions(PointLayoutPtr layout);
virtual void addArgs(ProgramArgs& args);
virtual PointViewSet run(PointViewPtr view);
};
} // namespace pdal
cmake_minimum_required(VERSION 3.13)
project(FilterTutorial)
find_package(PDAL 2.5 REQUIRED CONFIG)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(pdal_plugin_filter_myfilter SHARED MyFilter.cpp)
target_link_libraries(pdal_plugin_filter_myfilter PRIVATE ${PDAL_LIBRARIES})
target_include_directories(pdal_plugin_filter_myfilter PRIVATE ${PDAL_INCLUDE_DIRS})
target_link_directories(pdal_plugin_filter_myfilter PRIVATE ${PDAL_LIBRARY_DIRS})
find_package(PDAL 2.5 REQUIRED CONFIG) 是硬要求:CONFIG 模式才会加载 PDALConfig.cmake 并定义 PDAL_LIBRARIES/PDAL_INCLUDE_DIRS/PDAL_LIBRARY_DIRS 三个变量。add_library(... SHARED ...) 的 target 名字就是这个插件的文件名,见 13.3 节。
13.3 最小可加载的 filter
13.3.1 完整源码
// MyFilter.cpp
#include "MyFilter.hpp"
#include <pdal/pdal_internal.hpp>
namespace pdal
{
static PluginInfo const s_info
{
"filters.name", // 必须叫 filters.<名字>
"My awesome filter", // pdal --drivers 里显示的描述
"http://link/to/documentation" // 文档链接
};
// 关键一行:注册成运行时加载的插件
CREATE_SHARED_STAGE(MyFilter, s_info)
std::string MyFilter::getName() const { return s_info.name; }
void MyFilter::addArgs(ProgramArgs& args)
{
args.add("param", "Some parameter", m_value, 1.0);
}
void MyFilter::addDimensions(PointLayoutPtr layout)
{
layout->registerDim(Dimension::Id::Intensity);
m_myDimension = layout->registerOrAssignDim("MyDimension",
Dimension::Type::Unsigned8);
}
PointViewSet MyFilter::run(PointViewPtr input)
{
PointViewSet viewSet;
viewSet.insert(input);
return viewSet;
}
} // namespace pdal
三个 API 的作用:
addArgs(ProgramArgs&):声明 option。args.add(name, description, member, default)会同时把默认值写进--options的输出,所以用户执行pdal --options filters.name能看到param [1]。addDimensions(PointLayoutPtr):向点表注册维度。已有标准维度用registerDim(Dimension::Id::Intensity);自定义维度用registerOrAssignDim("名字", 类型),返回的Dimension::Id存下来供后面读写。run(PointViewPtr):非流式实现,拿到整个 view 的副本,返回新的PointViewSet。想原地改可以像示例一样把input直接塞回viewSet。
13.3.2 命名规则:文件、stage 名、宏三者必须对齐
pdal::PluginDirectory 启动时扫描目录,只认这种文件名:
libpdal_plugin_{reader|writer|filter|kernel}_{stage 短名}.{dll|so|dylib}
对应的 stage 全名是 {type}s.{短名}。举例:
| 文件名 | stage 名 |
|---|---|
libpdal_plugin_filter_myfilter.so |
filters.myfilter |
libpdal_plugin_reader_myreader.so |
readers.myreader |
pdal_plugin_writer_txt.dll |
不合法(缺 lib 前缀),不会被加载 |
注意最后一行的陷阱:validPlugin() 的校验是 file.find("libpdal_plugin_") == 0,要求文件名以 lib 开头。CMake 在 Linux/macOS 上给 SHARED 库自动加 lib 前缀,所以 add_library(pdal_plugin_filter_myfilter ...) 出来的就是 libpdal_plugin_filter_myfilter.so,刚好合法;Windows 下不加前缀,必须显式设置:
if (MSVC)
set_target_properties(pdal_plugin_filter_myfilter PROPERTIES PREFIX "lib")
endif()
PDAL 自己的插件用 PDAL_CREATE_PLUGIN 宏(cmake/pluginmacros.cmake)处理这个差异——它把 MSVC 下的 target 名直接写成 libpdal_plugin_...。手工写 CMake 时照这个规则来即可。
还要注意 stage 短名里不能带 filters. 前缀:s_info 里写 "filters.myfilter",文件名里只写 myfilter。两者对不上时的现象是插件被加载了(Debug 日志 Loaded plugin)但没有注册(”No symbol PF_initPlugin” 或注册表里没有该名字)。
13.3.3 静态注册:不改 PDAL 源码的替代方案
如果不想维护外部动态库,可以把 stage 直接编进自己的宿主程序,用 CREATE_STATIC_STAGE(T, info) 替代 CREATE_SHARED_STAGE:
#define CREATE_STATIC_STAGE(T, info) \
static bool T ## _b = \
pdal::PluginManager<pdal::Stage>::registerPlugin<T>(info);
区别只有一点:CREATE_SHARED_STAGE 生成导出符号 PF_initPlugin,由 PDAL 在 dlopen 后主动调用;CREATE_STATIC_STAGE 用静态变量初始化在 main() 之前完成注册。宿主程序完全不需要 PDAL_DRIVER_PATH,适合”PDAL 作为库嵌入自有软件”的形态。
13.3.4 验证三连
# 1) 能加载
export PDAL_DRIVER_PATH=/path/to/build
pdal --drivers | grep myfilter
# 2) 参数表正确
pdal --options filters.myfilter
# 3) 能跑
pdal pipeline test.json
Windows PowerShell 里第 1 行是 $env:PDAL_DRIVER_PATH = "D:\build"。如果 --drivers 里没有,开 Debug 日志看加载过程:
pdal --drivers --debug 2>&1 | grep -i "plugin\|can't load"
常见输出与含义:Can't load library ... 是动态库依赖缺失或 ABI 不匹配;Loaded plugin 后没有出现 stage 名,是注册宏没生效(多半写成了 CREATE_SHARED_STAGE 以外的宏,或文件名与 stage 名不匹配)。
13.4 流式 filter:继承 Filter 和 Streamable
13.4.1 为什么需要两个基类
PDAL 的 Filter 只保证非流式路径(run(PointViewPtr))。要让 pipeline 能以 --stream 或 execute_streaming 跑,必须同时实现 Streamable 的 processOne(PointRef&)。官方 filters.head 就是最小范例:
#include <pdal/Filter.hpp>
#include <pdal/Streamable.hpp>
namespace pdal
{
class HeadFilter : public Filter, public Streamable
{
public:
std::string getName() const;
private:
point_count_t m_count;
point_count_t m_index;
bool m_invert;
virtual void addArgs(ProgramArgs& args);
virtual bool processOne(PointRef& point);
virtual PointViewSet run(PointViewPtr view);
virtual void ready(PointTableRef table);
};
} // namespace pdal
对应的实现里,两个入口要保证语义一致:processOne() 返回 false 表示”丢弃该点”,run() 里对每个点调 processOne,为真的才 appendPoint:
bool HeadFilter::processOne(PointRef& point)
{
bool keep = (m_index < m_count);
m_index++;
return m_invert ? !keep : keep;
}
PointViewSet HeadFilter::run(PointViewPtr inView)
{
PointViewSet viewSet;
m_index = 0;
PointViewPtr outView = inView->makeNew();
for (PointRef point : *inView)
if (processOne(point))
outView->appendPoint(*inView, point.pointId());
viewSet.insert(outView);
return viewSet;
}
13.4.2 生命周期钩子
pdal/Stage.hpp 定义的虚函数按调用顺序:
| 钩子 | 时机 | 典型用途 |
|---|---|---|
addArgs(ProgramArgs&) |
构造后、解析 option | 声明参数与默认值 |
initialize(PointTableRef) |
prepare 早期 | 校验互斥参数 |
addDimensions(PointLayoutPtr) |
prepare 中 | 注册输出维度 |
prepared(PointTableRef) |
prepare 后段 | 依赖最终 schema 的初始化 |
ready(PointTableRef) |
开始处理前 | 重置计数器、打开文件 |
run(PointViewPtr) / processOne(PointRef&) |
处理阶段 | 实际逻辑 |
done(PointTableRef) |
结束后 | 关文件、写汇总元数据 |
prerun(const PointViewSet&) 只对多输入的 stage 有意义(merge 类)。把状态初始化放在 ready() 而不是构造函数里,是 PDAL stage 的惯例:构造函数会被 StageFactory::createStage() 调用,此时 option 还没解析。
13.4.3 自定义 reader/writer 的额外约定
reader 必须实现 read(PointViewPtr view, point_count_t count),返回本次实际读入的点数;返回 0 表示 EOF。writer 实现 write(const PointViewPtr)& 与 readyFile()/doneFile()。官方 examples/writing-reader 与 writing-writer 各给出了一份不到 120 行的完整实现,比看文档快。
reader 的 SRS 在 ready() 里用 setSpatialReference(ref) 声明:
void MyReader::ready(PointTableRef)
{
m_index = 0;
SpatialReference ref("EPSG:4385");
setSpatialReference(ref);
}
13.5 类型系统与数值写入
13.5.1 维度是强类型的
PDAL 的维度类型在注册时就固定了:registerOrAssignDim("Roughness", Dimension::Type::Double)。写入时用模板方法,类型不匹配会做转换而不是报错,但用错模板参数(例如把 double 写进 Unsigned8 维度)会静默截断:
PointLayoutPtr layout = view->layout();
Dimension::Id rough = layout->findProprietaryDim("Roughness");
view->setField(rough, pointId, 3.14); // 有隐式转换
double v = view->getFieldAs<double>(rough, pointId);
findProprietaryDim() 找不到时返回 Dimension::Id::Unknown。拿它去 setField 不会崩溃,但数据写不进去,表现为输出里该维度全是 0——这是自定义维度最常见的静默失败。
13.5.2 批量读写比逐点快
PointView 支持类型化数组访问:
auto x = view->getFieldAs<double>(Dimension::Id::X, 0); // 单点
// 批量:先拿指针,再按 layout 的 stride 走
char *base = view->getPoint(0);
逐点 getFieldAs/setField 每次都要查维度偏移,百万点级别会成为瓶颈。官方 filter 在热路径里通常先把 Dimension::Id 和偏移缓存到成员变量,或者在 prepared() 里取一次 layout 指针。
13.6 在自有程序里调用 PDAL
不想写 pipeline JSON 时,C++ 可以直接拼 pipeline。两种方式:PipelineManager(高层,帮你管理 stage 生命周期)与 StageFactory(低层,手工连 stage)。
13.6.1 StageFactory:手工连线
官方 examples/writing/tutorial.cpp 展示了完整流程:
#include <pdal/PointView.hpp>
#include <pdal/PointTable.hpp>
#include <pdal/Dimension.hpp>
#include <pdal/Options.hpp>
#include <pdal/StageFactory.hpp>
#include <io/BufferReader.hpp>
using namespace pdal;
int main()
{
Options options;
options.add("filename", "myfile.las");
PointTable table;
table.layout()->registerDim(Dimension::Id::X);
table.layout()->registerDim(Dimension::Id::Y);
table.layout()->registerDim(Dimension::Id::Z);
PointViewPtr view(new PointView(table));
for (int i = 0; i < 1000; ++i)
{
view->setField(Dimension::Id::X, i, -93.0 + i * 0.001);
view->setField(Dimension::Id::Y, i, 42.0 + i * 0.001);
view->setField(Dimension::Id::Z, i, 106.0 + i);
}
BufferReader reader;
reader.addView(view);
StageFactory factory;
Stage *writer = factory.createStage("writers.las"); // factory 拥有该对象
writer->setInput(reader);
writer->setOptions(options);
writer->prepare(table);
writer->execute(table);
}
要点:StageFactory::createStage() 返回裸指针,所有权归 factory(工厂析构时统一销毁),用 destroyStage() 可提前释放;prepare(table) 必须先于 execute(table);BufferReader 是内存数据源,头文件在 io/BufferReader.hpp(属于内部头,随安装包一起发布)。
13.6.2 PipelineManager:按 JSON 或按名字
PipelineManager 更适合”配置驱动”的场景:
#include <pdal/PipelineManager.hpp>
PipelineManager mgr;
mgr.readPipeline("pipeline.json"); // 读 JSON
mgr.execute(); // 返回点数
const PointViewSet& views = mgr.views();
也可以不读 JSON 直接加 stage:mgr.makeReader(file, "readers.las")、mgr.makeFilter("filters.smrf")、mgr.makeWriter(out, "writers.las"),返回值是 Stage&,可以链式设置 option。构造函数参数 streamLimit 默认 10000,就是流式执行的块大小——和 Python 绑定 iterator() 的默认 chunk_size 是同一个值。
13.7 排错清单
| 现象 | 原因 | 修法 |
|---|---|---|
pdal --drivers 看不到插件 |
PDAL_DRIVER_PATH 没设,或文件名不以 libpdal_plugin_ 开头 |
设环境变量;Windows 下给 target 加 lib 前缀 |
Can't load library |
依赖的 PDAL/GDAL 动态库找不到,或 ABI 不匹配 | 用 ldd/dumpbin /dependents 查缺哪个库;同环境编译 |
日志有 Loaded plugin 但 stage 不存在 |
s_info 名字与文件名短名不一致,或用了错误的注册宏 |
对齐三者:文件名、s_info.name、注册宏 |
No symbol 'PF_initPlugin' found |
用了 CREATE_STATIC_STAGE 编动态库 |
动态库必须用 CREATE_SHARED_STAGE |
stage 能创建但 --options 无参数 |
addArgs 没写,或参数名拼错 |
检查 args.add() 的第一个参数 |
| 自定义维度输出全 0 | findProprietaryDim 返回 Unknown,或维度没在 addDimensions 注册 |
用 registerOrAssignDim 返回的 Id,缓存到成员 |
| 流式模式报 “not streamable” | filter 没继承 Streamable 或没实现 processOne |
双继承 Filter, Streamable 并实现 processOne |
| 输出点数与预期不符 | run() 里的 appendPoint 条件与 processOne 语义不一致 |
让 run() 复用 processOne(),别写两套逻辑 |
13.8 生产建议
版本对齐用容器,别靠文档。 插件二进制对 PDAL 主库版本敏感,升级 PDAL 必须重编插件。CI 里固定基础镜像 tag,构建产物按 PDAL 版本分目录存放。
优先写 streamable filter。 非流式 run() 会把整个块读进内存,百万点以上差距明显;processOne 版本的实现通常只多十几行,但能进 --stream 流水线,也能被 execute_streaming 调用。
自定义维度名加前缀。 多个团队插件共存时,Roughness、density 这种通用名很容易和别的 stage 冲突。用 ACME_Roughness 之类的前缀,并在 addDimensions 里固定类型。
参数校验放在 initialize()。 互斥参数(如 in_srs 与 override_srs)、范围检查(如窗口半径必须 > 0)在那里抛 pdal_error,错误信息会带 stage 名,用户能定位。
别改 PDAL 源码树做私有分支。 自定义 stage 走外部插件,PDAL 升级只需重编和回归测试;改源码会把每次升级变成一次手工合并。
13.9 本章小结
| 主题 | 结论 |
|---|---|
| 注册宏 | 动态库用 CREATE_SHARED_STAGE(导出 PF_initPlugin),静态链接用 CREATE_STATIC_STAGE |
| 文件命名 | libpdal_plugin_filter_<短名>.so/.dll,Win 下需手动加 lib 前缀 |
| 搜索路径 | 环境变量 PDAL_DRIVER_PATH 优先,否则找 ./lib、./bin、安装目录等 |
| 必需覆盖 | addArgs(参数)、addDimensions(维度)、run(非流式) |
| 流式 | 双继承 Filter, Streamable 并实现 processOne(PointRef&) |
| 生命周期 | initialize → addDimensions → prepared → ready → run/processOne → done |
| C++ 调用 | StageFactory 手工连线;PipelineManager 支持 JSON 与 makeReader/makeFilter/makeWriter |
| 构建 | find_package(PDAL 2.5 REQUIRED CONFIG) + CMAKE_CXX_STANDARD 17 |