地下管网点云怎么渲?osgPotree + OpenTrench3D 分类显示全流程

前言

地下管线是城市的生命线,但埋在地下看不见;道路开挖、管线暴露的窗口期,是对沟槽做实测、摸清管线真实现状的最好机会。实测得到的是三维点云,而点云的价值在于分清每个点的身份------哪片是新铺的供水管、哪片是别家电缆、哪片只是土方。

本文以公开数据集 OpenTrench3D(310 个标注沟槽点云、约 5.28 亿点)为例,讲 osgPotree(基于 OSG 的点云库)分类显示的三个技术细节:

  1. PLY 字段的自动识别 与用户指定;
  2. 分类数据如何存入 osg::Geometry;
  3. 分类渲染与类别显示/隐藏如何实现。

每个点带属性 X, Y, Z, R, G, B, C,C 为类别号(0--4):

C 值 语义 说明
0 Main Utility 新铺主管线(供水/区域供热)
1 Other Utility 其它权属单位的既有管线
2 Trench 开挖沟槽
3 Inactive Utility 已停用管线
4 Misc 杂项,评估时忽略

源码对应 osg-potree master(f61958b)。


一、整体流程

数据线只经过一个插件 osgdb_ply;渲染线的设计原则是"属性进槽位、状态进纹理":类别号是数据,作为顶点属性跟着几何走;颜色和显隐是状态,放在一张纹理里------改样式只动纹理,不碰几何。
#mermaid-svg-aeRvVbhpswg4QMy5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aeRvVbhpswg4QMy5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aeRvVbhpswg4QMy5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aeRvVbhpswg4QMy5 .error-icon{fill:#552222;}#mermaid-svg-aeRvVbhpswg4QMy5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aeRvVbhpswg4QMy5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aeRvVbhpswg4QMy5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aeRvVbhpswg4QMy5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aeRvVbhpswg4QMy5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aeRvVbhpswg4QMy5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aeRvVbhpswg4QMy5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aeRvVbhpswg4QMy5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aeRvVbhpswg4QMy5 .marker.cross{stroke:#333333;}#mermaid-svg-aeRvVbhpswg4QMy5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aeRvVbhpswg4QMy5 p{margin:0;}#mermaid-svg-aeRvVbhpswg4QMy5 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-aeRvVbhpswg4QMy5 .cluster-label text{fill:#333;}#mermaid-svg-aeRvVbhpswg4QMy5 .cluster-label span{color:#333;}#mermaid-svg-aeRvVbhpswg4QMy5 .cluster-label span p{background-color:transparent;}#mermaid-svg-aeRvVbhpswg4QMy5 .label text,#mermaid-svg-aeRvVbhpswg4QMy5 span{fill:#333;color:#333;}#mermaid-svg-aeRvVbhpswg4QMy5 .node rect,#mermaid-svg-aeRvVbhpswg4QMy5 .node circle,#mermaid-svg-aeRvVbhpswg4QMy5 .node ellipse,#mermaid-svg-aeRvVbhpswg4QMy5 .node polygon,#mermaid-svg-aeRvVbhpswg4QMy5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aeRvVbhpswg4QMy5 .rough-node .label text,#mermaid-svg-aeRvVbhpswg4QMy5 .node .label text,#mermaid-svg-aeRvVbhpswg4QMy5 .image-shape .label,#mermaid-svg-aeRvVbhpswg4QMy5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-aeRvVbhpswg4QMy5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-aeRvVbhpswg4QMy5 .rough-node .label,#mermaid-svg-aeRvVbhpswg4QMy5 .node .label,#mermaid-svg-aeRvVbhpswg4QMy5 .image-shape .label,#mermaid-svg-aeRvVbhpswg4QMy5 .icon-shape .label{text-align:center;}#mermaid-svg-aeRvVbhpswg4QMy5 .node.clickable{cursor:pointer;}#mermaid-svg-aeRvVbhpswg4QMy5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-aeRvVbhpswg4QMy5 .arrowheadPath{fill:#333333;}#mermaid-svg-aeRvVbhpswg4QMy5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-aeRvVbhpswg4QMy5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-aeRvVbhpswg4QMy5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aeRvVbhpswg4QMy5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-aeRvVbhpswg4QMy5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aeRvVbhpswg4QMy5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-aeRvVbhpswg4QMy5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-aeRvVbhpswg4QMy5 .cluster text{fill:#333;}#mermaid-svg-aeRvVbhpswg4QMy5 .cluster span{color:#333;}#mermaid-svg-aeRvVbhpswg4QMy5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-aeRvVbhpswg4QMy5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-aeRvVbhpswg4QMy5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-aeRvVbhpswg4QMy5 .icon-shape,#mermaid-svg-aeRvVbhpswg4QMy5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aeRvVbhpswg4QMy5 .icon-shape p,#mermaid-svg-aeRvVbhpswg4QMy5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-aeRvVbhpswg4QMy5 .icon-shape .label rect,#mermaid-svg-aeRvVbhpswg4QMy5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aeRvVbhpswg4QMy5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-aeRvVbhpswg4QMy5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-aeRvVbhpswg4QMy5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} setClassificationStyle
OpenTrench3D PLY

X Y Z R G B C
osgdb_ply 插件

列名自动映射 + 用户指定
buildPointCloudNode
osg::Geometry

类别绑定到 6 号顶点属性
NodeVisitor 统计

分类直方图 256 项
按类别显示对话框
256x1 RGBA 的 Texture1D
classification 着色器
屏幕:按类着色 + 按类显隐

三步概括:插件把 C 列识别为分类号,绑定到几何的 6 号顶点属性;NodeVisitor 数出每个类的点数(直方图);渲染时着色器按分类号到一张 256×1 的纹理里查颜色和显隐。

分类显示

按RGB显示


二、细节一:PLY 字段自动识别与用户指定

PLY 只约定文件结构,不约定列名含义:同样是类别,有的数据叫 classification,有的叫 class,OpenTrench3D 叫 c。所以读文件的第一件事是列名映射,分自动、手动两层。

OpenTrench3D 的属性头大致是(属性名大小写以实际样本为准):

text 复制代码
ply
format binary_little_endian 1.0
element vertex 1835443
property double x
property double y
property double z
property uchar red
property uchar green
property uchar blue
property uchar c
end_header

自动识别 :对每个字段给一组候选名,按"从常见到简短"依次匹配,匹配前统一转小写(core/osgPotree/PlyHeader.cpp):

cpp 复制代码
PlyColumnMapping autoDetectPlyMapping(const PlyHeader& header)
{
    PlyColumnMapping mapping;
    const PlyElement* vertex = header.vertex();
    if (!vertex)
        return mapping;

    // 收集 vertex 元素的全部标量列,统一转小写做匹配
    std::vector<std::pair<std::string, std::string>> columns;
    for (const PlyProperty& property : vertex->properties)
    {
        if (!property.isList)
            columns.emplace_back(toLower(property.name), property.name);
    }

    auto findByNames = [&](std::initializer_list<const char*> names) -> std::string
    {
        for (const char* name : names)
        {
            const std::string wanted = toLower(name);
            const auto iterator = std::find_if(columns.begin(), columns.end(),
                [&](const std::pair<std::string, std::string>& column)
                { return column.first == wanted; });
            if (iterator != columns.end())
                return iterator->second;
        }
        return {};
    };

    mapping.x = findByNames({ "x" });
    mapping.y = findByNames({ "y" });
    mapping.z = findByNames({ "z" });
    mapping.red = findByNames({ "red", "r" });
    mapping.green = findByNames({ "green", "g" });
    mapping.blue = findByNames({ "blue", "b" });
    mapping.classification = findByNames({ "classification", "class", "category", "c" });
    // ...intensity / returnNumber / gpsTime 同理
    return mapping;
}

两个要点:classification 的候选列表收尾于单字母 "c",正好匹配 OpenTrench3D;red/green/blue 候选里的 "r"/"g"/"b" 用来兼容单字母或全大写(R)命名的数据集。

用户指定 :自动识别失手时,可通过 osgDB::Options 传 JSON 覆盖映射(plugins/osgdb_ply/ReaderWriterPly.cpp);Qt 客户端的 PLY 导入对话框列全部列名供逐字段指认,确认后即以此 JSON 形式传给插件:

cpp 复制代码
if (options && !options->getOptionString().empty())
{
    const nlohmann::json mapping = nlohmann::json::parse(options->getOptionString());
    const auto readField = [&](const char* key, std::string& value)
    {
        if (mapping.contains(key) && mapping[key].is_string())
            value = mapping[key].get<std::string>();
    };
    readField("classification", loadOptions.plyMapping.classification);
    // x / y / z / red / green / blue ... 同理
}

PLY 导入对话框如下所示:


三、细节二:分类数据如何存入 osg::Geometry

插件读点后先统一成标准属性(core/osgPotree/PointCloudStandardAttributes.h):

cpp 复制代码
struct PointCloudStandardAttributes
{
    std::vector<osg::Vec3d> positions;
    std::vector<osg::Vec4ub> colors;
    std::vector<std::uint8_t> classification;
    // ...intensity / returnNumber / gpsTime 等其余 LAS 标准属性
    bool hasClassification = false;
};

再由 buildPointCloudNode() 拼成 osg::Geometry。分类号不进顶点、不进颜色,而是占用一个顶点属性槽位 ------槽位即顶点属性编号(location),着色器里 layout(location = N) 的输入对应几何上 setVertexAttribArray(N, ...) 绑定的数组,编号一致才能对上。槽位在 core/osgPotree/PointCloudAttributeLayout.h 集中定义:

cpp 复制代码
namespace osgpotree
{
inline constexpr unsigned int kClassificationVertexAttrib = 6;
inline constexpr unsigned int kIntensityVertexAttrib = 7;
inline constexpr unsigned int kReturnNumberVertexAttrib = 8;
inline constexpr unsigned int kGpsTimeVertexAttrib = 9;
// ...其余标准属性依次占用 10--14 号
}

0--5 号留给 OSG 内置数组(本工程着色器用了 0 号 osg_Vertex、3 号 osg_Color),自定义属性从 6 号开始。绑定代码:

cpp 复制代码
if (attributes.hasClassification)
{
    osg::ref_ptr<osg::UByteArray> classes = new osg::UByteArray;
    classes->assign(attributes.classification.begin(), attributes.classification.end());
    geometry->setVertexAttribArray(kClassificationVertexAttrib, classes,
        osg::Array::BIND_PER_VERTEX);
}

buildPointCloudNode 另有两个细节:坐标先减包围盒中心再转 float(测量坐标数值大,直接转 float 丢精度;存相对坐标后数值小,float 够用),中心值放进外层 MatrixTransform;无颜色列时整列填 (205,205,205,255) 占位,避免 gl_Color 取到管线中残留的其他对象颜色。


四、细节三:分类渲染与类别显隐

4.1 一张纹理编码颜色与显隐

需求:256 个类各自配色、各自开关;改样式不能重造几何(百万点过一遍 CPU 代价太大);能与透明度、EDL 叠加。

解法:一张 256×1 像素的 RGBA Texture1D,第 i 个像素对应类别 i------RGB 是颜色,A 是显隐 。每类的显示状态存在(core/osgPotreePropertyCore/LasClassification.h):

cpp 复制代码
// Per-class display state. The 256-entry range covers the complete LAS
// classification byte, including the reserved ASPRS codes.
struct ClassificationStyle
{
    std::array<bool, 256> visible;
    std::array<osg::Vec4ub, 256> colors;

    ClassificationStyle();
    void resetColors();   // 恢复 ASPRS 默认配色
};

写入纹理(DrawableObject.cpp):

cpp 复制代码
void PointCloudObject::updateClassificationTexture()
{
    unsigned char* data = _classColorImage->data();

    for (int i = 0; i < 256; ++i)
    {
        const osg::Vec4ub& color = _classificationStyle.colors[static_cast<std::size_t>(i)];
        const std::size_t offset = static_cast<std::size_t>(i) * 4;
        data[offset + 0] = color.r();
        data[offset + 1] = color.g();
        data[offset + 2] = color.b();
        // 可见性藏在 alpha 里:不可见的类 alpha = 0
        data[offset + 3] = _classificationStyle.visible[static_cast<std::size_t>(i)] ? 255 : 0;
    }

    _classColorImage->dirty();
    _classColorTexture->dirtyTextureObject();
}

纹理过滤设 NEAREST(查表取整格,不插值),寻址设 CLAMP_TO_EDGE。

4.2 着色器

顶点着色器把 6 号槽位的分类号透传给片元:

glsl 复制代码
#version 330
layout(location = 0) in vec3 osg_Vertex;
layout(location = 6) in float osg_Classification;
uniform mat4 osg_ModelViewProjectionMatrix;
out float vClassification;
void main()
{
    vClassification = osg_Classification;
    gl_Position = osg_ModelViewProjectionMatrix * vec4(osg_Vertex, 1.0);
}

片元着色器十行,是整个方案的核心:

glsl 复制代码
#version 330
uniform sampler1D u_classColors;
uniform float uOpacity;
in float vClassification;
out vec4 fragColor;
void main()
{
    int classId = clamp(int(vClassification + 0.5), 0, 255);
    vec4 classColor = texelFetch(u_classColors, classId, 0);
    if (classColor.a < 0.5)
    {
        discard;   // 不可见的类:直接不产生片元
    }
    fragColor = vec4(classColor.rgb * uOpacity, uOpacity);  // 预乘 alpha
}

三个设计点:

  • texelFetch 查表而非 texture 采样 。类别号是整数,要的是"取第几格"。texelFetch 直接用纹素下标取值,不经过采样器(无过滤、无归一化),配合 NEAREST,类别 3 一定取到第 3 格,不会差格、不会混色。
  • alpha + discard 实现显隐。隐藏某个类只需把对应像素的 alpha 从 255 改成 0------一次 1KB 纹理更新,几何一字节不动。显示状态全在纹理里、几何不携带任何显示信息,这是改样式轻量的根本原因。
  • 预乘 alpha 输出 。vec4(rgb * uOpacity, uOpacity) 配合根节点 BlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA),与其他着色模式走同一条混合管线,透明度行为一致。

4.3 状态组装与 UI 生效

applyRenderType() 在数据节点外套一个 Group,纹理、uniform、program 全放在壳的 StateSet 上------切换显示模式就是换壳,数据不动(DrawableObject.cpp):

cpp 复制代码
if (_colorMode == static_cast<int>(ColorMode::Classification) && _hasClassification)
{
    osg::ref_ptr<osg::Group> classificationNode = new osg::Group;
    classificationNode->addChild(_contentNode.get());

    osg::StateSet* stateset = classificationNode->getOrCreateStateSet();
    const int textureUnit = 3;
    stateset->setTextureAttributeAndModes(textureUnit, _classColorTexture.get(),
        osg::StateAttribute::ON);
    stateset->addUniform(new osg::Uniform("u_classColors", textureUnit));
    if (_opacityUniform.valid())
        stateset->addUniform(_opacityUniform.get());

    osg::ref_ptr<osg::Program> program = new osg::Program;
    program->setName("classificationColors");
    program->addShader(new osg::Shader(osg::Shader::VERTEX, classification_vertSrc));
    program->addShader(new osg::Shader(osg::Shader::FRAGMENT, classification_fragSrc));
    stateset->setAttributeAndModes(program, osg::StateAttribute::ON | osg::StateAttribute::OVERRIDE);

    coloredNode = classificationNode;
}

OVERRIDE 强制这份 program 生效,压过根节点上默认的透明度 program;再开 EDL 时会再包一层壳,因此分类色与 EDL 可叠加。一个坑:着色器用了 osg_ModelViewProjectionMatrix,必须先在 setRenderContext() 里调 getState()->setUseModelViewAndProjectionUniforms(true),否则点云整帧消失。

UI 侧:右键菜单仅当 hasClassification() 为真时显示"按类别显示...";对话框要列出"实际存在"的类,靠的是 NodeVisitor 遍历几何、把 6 号槽位的 UByteArray 数一遍得到的 256 项直方图(与渲染读同一列数据,菜单和屏幕永远一致):

cpp 复制代码
void apply(osg::Geometry& geometry) override
{
    osg::Array* rawArray = geometry.getVertexAttribArray(kClassificationVertexAttrib);
    if (osg::UByteArray* classes = dynamic_cast<osg::UByteArray*>(rawArray))
    {
        for (const auto value : *classes)
            ++_histogram[static_cast<std::size_t>(value)];   // histogram[类] = 点数
    }
    traverse(geometry);
}

对话框只列直方图计数大于 0 的类,每类一行(勾选、类 ID、类名、颜色)。确定后 setClassificationStyle() 回写样式,唯一的后续动作就是 updateClassificationTexture()------无几何重建、无数据重读、无场景图变动,下一帧生效。


五、实战提示

bash 复制代码
# PLY 直读,属性列自动映射,c 列自动认作分类
osgviewer D:/OpenTrench3D/water/area1/cloud_0001.ply

或用 Qt 客户端 osgPotreeViewerQt 把 .ply 拖进数据面板(列映射不对时在导入对话框指认)。

注意一个语义错位:OpenTrench3D 的类号 0--4 原样进了分类通道,界面显示的是 LAS/ASPRS 标准类名------"新装主管线"显示为"创建点,未分类"、"沟槽"显示为"地面点"。显示机制不受影响(类号只是纹理下标),实战做法:用预设调色板重着色(0 主管线红、1 他营蓝、2 沟槽灰、3 停用橙),并取消勾选 4(Misc)隐藏杂物------正好对应论文"评估时忽略 Misc"的设定。想要正确类名,做一张 OpenTrench3D 专用类名表挂在 LasClassification 旁即可。


六、总结

三个细节,三句话:

  1. 字段识别 ------候选名探测(classification/class/category/c 依次匹配、统一转小写)自动识别列,JSON / 导入对话框手动兜底;
  2. 分类存储 ------分类号绑定为 osg::Geometry 的 6 号顶点属性(UByteArray、BIND_PER_VERTEX),随几何走,槽位号即着色器的 location;
  3. 分类渲染 ------256×1 RGBA 纹理编码颜色(RGB)与显隐(A),片元着色器 texelFetch 查表、alpha 为 0 即 discard;改样式只需更新这张 1KB 纹理。

本质仍是"属性进槽位、状态进纹理":数据记录事实(这个点是哪类),状态决定表达(这个类怎么显示),两者以槽位号、纹理下标为接口解耦。照此套路,按强度、按回波、按 GPS 时间的显示只是换槽位、换纹理的事。


参考资料