2026-04-25

C++程序设计课程设计:一个交互式点云框架的架构设计

✍️ LiGuiyu & 桂鱼养的龙虾🦞
C++PCL点云架构设计课程设计NUAAImGui流水线模式

本文完整记录了 Point-Cloud-Pipeline-Control 的架构设计思路——一个基于 ImGui + PCL 的交互式点云处理与可视化框架。全文通过设计理念、类层次结构、流水线编排、数据流分析、线程模型五个维度,层层拆解这个项目的设计哲学。


[!NOTE] 此程序设计课程设计 GitHub 代码仓库:Point-Cloud-Pipeline-Control

[!TIP] 希望通过本文给修读「程序设计课程设计」课程的同学提供一些选题/设计的灵感

一、写在前面:设计理念

做这个项目的初衷很简单:我们想要一个能「搭积木」一样组合点云处理步骤的工具。

不是写死一个固定的处理链条,而是让用户能在 GUI 里自由地插入滤波、提取特征、对比效果。每一步做的什么、参数是什么、效果怎么样,一眼就能看到

基于这个目标,我们确立了项目的两个核心设计理念:

理念 本质 实现手段
模块化 加新功能不改旧代码 基类+派生类体系
流水线 自由组合、即时反馈 Pipeline 编排 + 惰性执行

这两个理念相互支撑——模块化提供了「积木块」,流水线提供了「搭积木的方式」。

以下全文逐层展开这两个理念的具体设计。 详细的完整实现说明,可参阅项目附带的 ARCHITECTURE.md —— 本文是它的解析与延伸。


二、模块化:基类 + 派生类体系

整个框架的积木化能力,建立在两层抽象的基类之上。

2.1 FilterBase:滤波抽象

相关文件: include/FilterBase.hppinclude/Filters.hppsrc/Filters.cpp

// 抽象基类:定义一切滤波器的接口
class FilterBase {
public:
    virtual ~FilterBase() = default;
    virtual std::string getName() const = 0;          // 返回滤波器名称,用于日志
    virtual void apply(pcl::PointCloud<pcl::PointXYZ>::Ptr& cloud) = 0;  // 修改传入的 shared_ptr
};

三个核心派生类,覆盖了点云处理的三种最常用操作:

滤波器 参数 效果 应用场景
PassThroughFilter 坐标轴 (x/y/z)、上下限 按坐标轴范围裁剪 去背景、提取 ROI
VoxelGridFilter 体素边长 leaf_size 体素网格降采样,保留点云空间结构 数据量压缩、加速后续计算
StatisticalOutlierFilter 近邻数、标准差倍数 统计法剔除离群点 消除噪声、平滑点云

所有滤波器的共同特征是 直接修改传入的 shared_ptr<PointCloud<PointXYZ>>,即原地替换数据。这意味着系统不需复杂的返回值管理——调用完 apply() 后,原来的指针已指向新数据。

设计权衡:为什么不是返回新云而是修改指针?因为这样可以无感地在流水线中传递——每个步骤处理的都是同一个指针,但指向的数据在不断变化。配合 shared_ptr 的引用计数,旧数据在不再被引用时自动释放。

三个滤波方法的公共模式「setInputCloud → filter → 替换指针」已通过模板函数 apply_pcl_filter<T>() 统一抽取,消除了重复代码。每个滤波 apply() 只需配置各自独特的 PCL 参数,最后一行调用模板函数完成执行。

2.2 FeatureBase<T>:特征抽象

相关文件: include/FeatureBase.hppinclude/FeatureExtractors.hpp

// 模板基类:泛化到任意点类型 x 任意特征类型
template <typename PointInT, typename FeatureOutT>
class FeatureBase {
public:
    using PointCloudIn  = pcl::PointCloud<PointInT>;
    using FeatureCloudOut = pcl::PointCloud<FeatureOutT>;

    virtual ~FeatureBase() = default;
    virtual FeatureCloudOutPtr extract(const PointCloudInPtr& input) = 0;
};

为什么用模板而不是直接继承 PointXYZ?因为模板保留了扩展到其他点类型(如带颜色的 PointXYZRGB、带强度的 PointXYZI)的可能性。框架目前只处理 PointXYZ,但架构已经为未来准备好了扩展点。

目前的两个特征提取器:

提取器 输入→输出 计算原理 可视化用法
NormalExtractor PointXYZNormal kd-tree 领域搜索 → PCA 拟合平面 → 法线方向 PCL Visualizer 自带法线渲染
CurvatureExtractor PointXYZPrincipalCurvatures 基于法线 → Hessian 矩阵 → 主曲率 数值存入 PointNormal 输出文件

特征提取与滤波有一个重要区别:特征提取不修改输入点云,返回新的特征点云。 也就是说,它负责「附加信息」,而不是「改变数据」。

法线复用:避免重复计算

CurvatureExtractor 支持外部注入法线,避免在流水线中重复计算:

class CurvatureExtractor : public FeatureBase<...> {
public:
    void setInputNormals(pcl::PointCloud<pcl::Normal>::ConstPtr normals);
};
  • 如果外部通过 setInputNormals() 注入了法线,extract() 直接复用,跳过内部计算
  • 如果没有注入法线(独立使用曲率提取时),extract() 会自动内部计算法线,保持接口兼容性
  • Pipeline 层面自动检测:当同时配置了法线和曲率提取器时,execute() 先算法线,再 setInputNormals(normals_) 传入曲率提取器

三、流水线:Pipeline 编排

3.1 Pipeline 类的设计

相关文件: include/Pipeline.hppsrc/Pipeline.cpp

class PointCloudPipeline {
public:
    void addStage(shared_ptr<FilterBase> filter);         // 追加滤波步骤
    void setNormalExtractor(shared_ptr<NormalExtractor>); // 设置法线提取器
    void setCurvatureExtractor(shared_ptr<CurvatureExtractor>);  // 设置曲率提取器

    pcl::PointCloud<pcl::Normal>::Ptr getNormals() const;
    pcl::PointCloud<pcl::PrincipalCurvatures>::Ptr getCurvatures() const;

    void execute(pcl::PointCloud<pcl::PointXYZ>::Ptr& cloud);  // 核心:执行所有已注册步骤
};

这个接口非常简洁——四个「配置方法」、两个「结果获取」、一个「执行」。你只需要:

① 调用 addStage() / setExtractor() 配置步骤
② 调用 execute() 一次性处理
③ 调用 getNormals() / getCurvatures() 取出结果

execute() 内部流程:

① 设置 executed_ = true(标记已执行)
② 遍历 stages_ → 依次执行每个 filter(每步记录日志:名称+前后点数)
③ 如有 normal_extractor_ → 对滤波结果计算法线
④ 如有 curvature_extractor_:
    · 如果 normals_ 已有数据 → setInputNormals(normals_) 复用,免重复计算
    · 否则内部自行计算法线
    · 计算曲率

防御性设计getNormals()getCurvatures() 内部有 assert(executed_) 保护——如果你在 execute() 之前调用它们,Debug 构建下会直接断言失败。这让「execute() 必须先调用」的时序约束从隐式约定变成了显式检查。

3.2 「惰性执行」的设计

整个框架最核心的设计技巧,藏在执行引擎里——惰性执行(Lazy Execution)

什么意思?当用户在 GUI 中配置步骤时,不是每配置一个步骤就立即执行一次。相反,所有滤波和特征提取步骤被「记录下来」——滤波放入 stages_ 向量,特征提取设置相应的 extractor。只有当遇到「显示步骤」时,才一次性将所有积压的操作执行完。

用户配置:  PassThrough(z,0,2) → VoxelGrid(0.01) → ShowCloud(保存)
                  │                    │                    │
内部状态:   pipeline: [PT]      pipeline: [PT, VG]          |→ flush → execute()
                                                            |→ 截取快照
                                                            |→ save → 新建 pipeline

这个设计的优势:

好处 说明
性能优化 N 个滤波 + 1 次显示 = 1 次 execute(),而不是 N+1 次
特征完整 特征提取在滤波之后统一执行,保证拿到的是「最终版本」的结果
显示准确 每个显示步骤截取的是当前状态的快照,互不干扰

3.3 「隔断」与 Pipeline 重建

当某个步骤勾选了「保存输出」时,事情变得更有趣了。

保存前 → pipeline.execute() → 执行到当前为止的所有步骤 → 保存结果
保存后 → 新建一个空的 Pipeline 实例 → 后续步骤注册到新 Pipeline

为什么要重建? 因为在保存之后,已执行的 pipeline 内部状态(法线/曲率)已经被 getNormals() / getCurvatures() 取出了。后面的步骤需要从一个干净的状态重新开始。重建 Pipeline 保证了每一段独立的处理周期都有一个清晰的开始和结束。

这是「流水线隔离」原则的具体体现。详细的数据流分析可以看 ARCHITECTURE.md 中的场景 3 跟踪。


四、数据流:特征的生命周期

特征管理是整个框架中最微妙的部分。简单来说就是:法线和曲率特征附属于「点的索引」——只要点的顺序或数量变了,旧特征就废了。

4.1 安全的特征累积

当你在流水线中连续做多个特征提取(比如先做法线、再做曲率),框架通过两条路径累积特征:

  • 文件保存层面previous_feature_cloud 机制,在 build_featured_point_cloud() 中累积历史特征
  • 内存计算层面:Pipeline 层面的法线复用,直接将 execute() 中已算好的法线注入曲率提取器
ShowNormals(保存) → ComputeCurvature(保存)
       │                    │
       ▼                    ▼
  execute() 算法线      execute() 先检测 normals_ 已有数据
       │                    │ → setInputNormals(normals_) 复用
       ▼                    ▼ → 计算曲率(跳过重复的法线计算)
save(cloud + normals)   save(cloud + curvatures + 复用法线)
       │                    │
       ▼                    ▼
prev_feature_cloud ← 法线   复用 prev ← 已有法线 + 新曲率 → 合并写入

关键在于 build_featured_point_cloud 函数:

auto featured = build_featured_point_cloud(
    cloud,                    // 当前点云
    normals,                  // 最新的法线(可能为空)
    curvatures,              // 最新的曲率(可能为空)
    prev_featured_cloud       // 旧的特征云(已含历史特征)
);
// 函数内部:先复制 prev 的数据,再用最新提取的特征覆盖

4.2 滤波=特征失效

什么时候废弃特征? 当流水线中出现了滤波步骤时。

因为 PassThrough、VoxelGrid、StatisticalOutlier 都会改变点云的点数或顺序,之前保存的 previous_feature_cloud 在新的点云上完全不适用——法线/曲率数据会对应到错误的点上。

// flush_pending_pipeline 中的关键逻辑:
if (filters_pending_in_pipeline) {
    previous_feature_cloud_valid = false;  // 废弃旧特征
    filters_pending_in_pipeline = false;
}

这个布尔值就像一把「安全锁」:只要滤波执行过,后续的保存操作就只存 XYZ,不再试图合并旧特征。


五、线程模型与可视化

5.1 执行引擎:关注点分离

流水线执行逻辑被封装为独立函数 execute_pipeline_steps(),通过两个结构体传递上下文和结果:

// 输入:流水线执行所需的上下文
struct PipelineExecContext {
    pcl::PointCloud<pcl::PointXYZ>::Ptr& cloud;  // 会被滤波步骤原地修改
    const std::string& output_folder;
    const std::string& current_file_name;
    std::shared_ptr<ProcessingLog> logger;
};

// 输出:执行结果
struct PipelineExecResult {
    std::vector<VisualizerData::StageSnapshot> snapshots;
    size_t original_count = 0;
    size_t final_count = 0;
};

PipelineExecResult execute_pipeline_steps(
    const std::vector<PipelineStepConfig>& steps,
    PipelineExecContext& ctx);

这个函数遍历用户配置的步骤列表,构建 Pipeline 实例,内部通过三个闭包(flush_pending_pipelinewrite_step_outputcapture_display_snapshot)协调滤波、特征提取、快照截取和文件保存的时序。

main() 中的「执行流水线」按钮回调非常简洁:

PipelineExecContext exec_ctx{cloud_to_process, get_output_folder(current_file_path),
                             current_file_name, logger};
PipelineExecResult exec_result = execute_pipeline_steps(pipeline_steps, exec_ctx);

vis_data->stage_snapshots = std::move(exec_result.snapshots);
vis_data->should_update = true;
logger->log("CustomPipeline", exec_result.original_count, exec_result.final_count);

执行引擎可以脱离 GUI 单独调用——这是可测试性的前提。

5.2 双线程架构

详细可视化流程见 ARCHITECTURE.md 可视化章节

    主线程                      Visualizer 线程
   (ImGui)         mutex            (PCL)
 ============   ◄────────►   ====================
   渲染 GUI         保护        viewer->spinOnce
  构建流水线                     rebuild_scene()
   写入快照                         更新视口
   标记更新                         渲染法线

关键设计:唯一的共享状态 VisualizerDatastd::mutex 保护。 主线程写入快照后设置 should_update = true,Visualizer 线程在下一帧检测到标记后重建场景。主线程和 Visualizer 线程对共享数据的所有读写操作均在锁内进行,彻底消除 data race。

struct VisualizerData {
    std::mutex mutex;

    pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_original;
    DisplayConfig original_display;
    std::vector<StageSnapshot> stage_snapshots;
    bool should_update = false;
};

5.3 多视口对比

每次执行流水线后,Visualizer 线程会根据快照数量动态划分视口:

视口 内容 颜色方案
视口 0(固定) 原始点云 白色,带坐标轴
视口 1 流水线显示步骤 A 用户自定义颜色
视口 2 流水线显示步骤 B 可自定义不同颜色
视口 N ... ...

每个视口独立配置背景色、点颜色、点大小、坐标轴、法线显隐。这就是「对比友好」的设计原则——原始点云始终在左上角,处理结果依次排开,随时对照。


六、IO 与日志

6.1 PointCloudIO:模板化的文件读写

相关文件: include/PointCloudIO.hppsrc/PointCloudIO.cpp

class PointCloudIO {
public:
    static PointCloud<PointXYZ>::Ptr load(const string& filename);
    template <typename PointT>
    static bool save(const string& filename, const PointCloud<PointT>& cloud);
};

支持的三种格式:

格式 特点 典型用途
.pcd PCL 原生格式,无损元数据 内部处理/调试
.ply Stanford 标准格式 公开分享/跨工具协作
.bin KITTI 数据集格式(4 float/点: xyz+intensity) 自动驾驶数据源

模板化的 save<T> 可以处理 PointXYZ(纯坐标)、PointNormal(含法线和曲率)等任意点类型,无须为每种类型写一个 save 函数。load() 在文件不存在或路径为空时会提前返回 nullptr 并打印友好错误信息。

6.2 Logger:结构化日志

每次操作都会记录到日志文件:

[2026-04-24 15:30:00] Operation: VoxelGridFilter | Pts before: 35947 | Pts after: 8923 | Diff: -27024

日志内容包括时间戳、操作名、处理前后的点数差异——这不仅是调试工具,也是数据处理的实验记录。


七、设计原则(七条)

回顾整个设计,现在可以用七条原则来概括:

1. 模块化

加新的滤波只需继承 FilterBase,加新的特征只需继承 FeatureBase<T>。滤波的公共「setInputCloud → filter → 替换指针」模式已抽取为 apply_pcl_filter<T>() 模板函数。不改旧代码,只加新文件。

2. 线程安全

唯一的共享状态 VisualizerDatastd::mutex 保护,主线程和 Visualizer 线程的所有读写操作均在锁内进行。

3. 流水线隔离

当某步骤勾选「保存输出」时,先执行已积压的步骤 → 保存 → 新建 Pipeline 实例。每段处理周期有清晰的起止点。

4. 特征复用

Pipeline 层面自动将法线计算结果传入曲率提取器,避免重复的 kd-tree 查询和法线估计。CurvatureExtractor 同时支持独立运行(无外部法线时自动计算),接口保持向后兼容。

5. 可扩展

FeatureBase 模板已保留扩展到 PointXYZRGBPointXYZI 的空间;PointCloudIO::save<T> 可处理任意点类型。

6. 对比友好

每步显示独立视口,原始点云固定左上角视口,随时对照。PassThrough 步骤的 UI 在 min >= max 时会显示红色警告。

7. 关注点分离

流水线执行逻辑封装为 execute_pipeline_steps() 独立函数,main() 只负责 UI 渲染和线程管理。执行引擎可脱离 GUI 单独调用和测试。


八、代码足迹

项目代码量统计:

文件 行数 职责
src/main.cpp 939 行 GUI + 流水线执行引擎
src/PointCloudIO.cpp 108 行 点云文件 IO
src/FeatureExtractors.cpp 66 行 特征提取实现
src/Filters.cpp 60 行 滤波实现
src/Pipeline.cpp 55 行 流水线编排实现
src/Logger.cpp 43 行 日志实现

连同 include/ 的 7 个头文件(174 行),项目总计 1445 行代码


写在最后

这个项目「小」吗?从代码量来看确实小——不到 1500 行业务代码。但它的架构逻辑却把一个完整的处理流程拆解成了「积木块 + 搭积木方式」,从基类设计到流水线编排,从特征生命周期到线程安全,每一层都有清晰的边界和责任。

对于课程设计来说,这种设计最大的意义不在于写了多少行代码,而在于展示了什么叫「面向扩展设计」。当你在 GUI 里添加一个新的滤波类型、一个新的特征提取器时,你会发现,代码结构已经完美处理了新的更改。

[!TIP] 所有的源文件、详细的架构文档、测试数据,都可以在项目仓库中找到:Point-Cloud-Pipeline-Control

评论 (0)