在
Ubuntu中进行激光雷达点云处理和多设备标定时,总是需要写大量测试脚本处理和查看点云,有时还需要借助第三方工具,总觉得不是很方便。总想写一个专门搞这些事情的工具,但是总嫌麻烦进展太慢,看看claude code能否帮我完成这个任务,先和它沟通了一版,考考它。
1. 需求概述
当前工程通过硬编码按钮加载和操作点云,存在以下问题:
- 每个点云需手动创建按钮和槽函数,扩展性差
- 中间处理结果无统一存储,需单独编写保存逻辑
- 联合计算(如 ICP 匹配)需手动编写胶水代码
- 坐标系变换结果无处存放,无法便捷复现
目标:参考 CloudCompare 的设计,建立**场景树(Scene Tree)**管理体系,将点云及其处理结果、参考元素组织为树形层级结构,实现灵活的可视化、操作和计算。
2. 核心概念
| 概念 | 说明 |
|---|---|
| 场景树节点 (SceneNode) | 树中的通用节点,包含元数据、显示属性、类型标识 |
| 点云节点 (CloudNode) | 继承 SceneNode,封装 pcl::PointCloud 数据 |
| 参考元素 (GeoRef) | 挂载在点云节点上的几何参考对象(点、线、面) |
| 操作链 | 从父节点到子节点的变换历史,子节点可追溯到根节点形成完整操作链 |
| 当前选中 | 选中一个节点,主界面的操作按钮作用于该节点。按住 Ctrl 支持多选,但多选时不提供裁剪和 ICP 功能 |
| 变换注册表 | 集中存储坐标系变换参数,支持保存/加载/快速调用 |
3. 场景树结构
[SceneRoot 场景根节点]
├── [点云A] ────────────────────── 顶层原始点云
│ ├── [直通滤波结果] ← 子节点(处理结果)
│ │ ├── [欧氏聚类#0] ← 子节点的子节点
│ │ └── [欧氏聚类#1]
│ ├── [旋转平移结果]
│ └── [Polygon裁剪结果]
│ ├── [参考点 p0, p1, p2] ← 参考元素
│ ├── [参考线 L1]
│ └── [拟合平面 Plane_A]
├── [点云B] ────────────────────── 顶层原始点云
│ ├── [降采样结果]
│ │ └── [ICP配准(配准到点云A)] ← 多节点联合操作结果
│ └── [参考平面 Plane_B]
└── [点云C]
└── ...
规则:
- 从外部加载的原始点云都放在顶层(SceneRoot 的直接子节点),平级
- 对任何节点执行操作后,结果作为子节点挂载到该节点下
- 选中某个节点,操作作用于该节点,结果挂在该节点下
- 按住 Ctrl 多选,支持联合操作(联合标定/ICP),详见 5.3 节
- 每个节点知道自己是如何生成的(父节点 + 操作类型 + 参数)
4. 数据结构设计
4.1 标量场(Scalar Field)
参考 CloudCompare 的标量场设计,支持为每个点云挂载多个 per-point 数值数组。
每个标量场有独立的名字、颜色标尺,可用于可视化(映射为颜色)、过滤、导出。
cpp
// 颜色标尺:将标量值映射为 RGB
struct ColorScale {
struct Stop {
double value; // 标量值
QColor color; // 对应颜色
};
QVector<Stop> stops; // 至少 2 个,如 blue(0) → red(100)
QColor sample(double scalarValue) const; // 线性插值
};
// 标量场:per-point 数值数组
class ScalarField {
public:
ScalarField(const QString& name, int pointCount);
void setValue(int index, double value);
double value(int index) const;
int count() const;
// 统计信息(懒计算)
double minVal() const;
double maxVal() const;
void recomputeBounds();
// 名字与颜色标尺
QString name() const;
void setName(const QString& name);
ColorScale colorScale() const;
void setColorScale(const ColorScale& scale);
private:
QString m_name;
QVector<double> m_data; // per-point 值
double m_min = 0, m_max = 0;
bool m_boundsValid = false;
ColorScale m_colorScale; // 默认蓝→红渐变
};
与点云的关系:
cpp
// 在 CloudNode 中管理标量场(见 4.7 节):
// QList<ScalarField*> scalarFields() const;
// void addScalarField(ScalarField* field); // 取得所有权
// void removeScalarField(int index);
// void setActiveScalarField(int index); // -1 表示无活动标量场
// int activeScalarFieldIndex() const;
渲染模式(三选一):
cpp
enum class ColorMode {
UniqueColor, // 整个点云一个固定颜色(DisplayProperty::color)
ScalarField, // 根据 activeScalarField + 对应 ColorScale 着色
PerPointColor, // 每个点独立 RGBA 颜色(预留,后期实现)
};
4.2 显示属性
cpp
struct DisplayProperty {
bool visible = true; // 是否在 VTK 中显示
float point_size = 1.0f; // 点渲染大小
float opacity = 1.0f; // 透明度 0~1
QColor color = Qt::white; // 唯一颜色模式下的颜色
ColorMode colorMode = ColorMode::UniqueColor;
bool showBBox = true; // 是否在 VTK 中显示 AABB 线框
};
4.3 操作类型枚举
两层设计:
OpCategory大类 +OpName字符串。便于后续插件动态注册新操作类型。
cpp
enum class OpCategory {
None, // 原始加载
Filter, // 滤波
Crop, // 裁剪
Transform, // 变换(旋转/平移/缩放)
Cluster, // 聚类
Registration, // 配准
Fit, // 几何拟合
};
struct OpInfo {
OpCategory category = OpCategory::None;
QString name; // 操作名称(如 "体素下采样"、"直通滤波_X")
QString label; // 显示名(如 "直通滤波_X_1~11")
double timestamp = 0.0;
};
4.4 操作记录(操作链追溯)
cpp
struct OpRecord {
OpInfo info; // 操作分类 + 名称 + 时间戳
QMap<QString, QVariant> params; // 操作参数 key-value
};
4.5 参考元素基类 + 子类
cpp
enum class GeoType { Point, Line, Plane };
struct GeoRef {
GeoType type;
QString name;
QColor color;
float size; // 点大小 / 线宽 / 面透明度
// 参考点:多个 3D 坐标
QVector<QVector3D> points;
// 参考线:起点 + 方向(从多点拟合)
QVector3D line_origin;
QVector3D line_direction;
// 参考面:ax+by+cz+d=0(从多点/区域拟合)
float plane_a = 0, plane_b = 0, plane_c = 0, plane_d = 0;
};
4.7 变换注册表
cpp
struct TransformEntry {
QString name; // 变换名称,如 "Car_To_World"
QString source_cloud; // 源点云名称(全路径)
QString target_cloud; // 目标点云名称(全路径)
Eigen::Matrix4f matrix; // 4x4 变换矩阵
double timestamp; // 记录时间
QString remark; // 备注
};
class TransformRegistry {
public:
static const int CURRENT_VERSION = 1;
void add(const TransformEntry& entry);
TransformEntry get(const QString& name) const;
QList<TransformEntry> listAll() const;
void remove(const QString& name);
bool saveToJson(const QString& path) const;
bool loadFromJson(const QString& path);
private:
QMap<QString, TransformEntry> m_entries; // key = name
};
4.8 场景树节点
cpp
enum class NodeType {
Cloud, // 点云节点
Group, // 分组/文件夹节点(无几何数据)
Mesh, // 网格节点(预留)
Polyline, // 曲线节点(预留)
GeoRef, // 参考元素节点(预留,Phase 4)
};
// 全局唯一 ID 生成器(线程安全)
class UniqueIdGenerator {
public:
static quint64 next() { return m_counter.fetch_add(1) + 1; }
private:
static QAtomicInteger<quint64> m_counter;
};
class SceneNode {
public:
explicit SceneNode(NodeType type, SceneNode* parent = nullptr);
virtual ~SceneNode();
// 唯一标识
quint64 id() const; // 构造时由 UniqueIdGenerator 分配
// 层级关系
SceneNode* parent() const;
QList<SceneNode*> children() const;
void addChild(SceneNode* child); // 自动 re-parent
void removeChild(SceneNode* child);
int indexOf(SceneNode* child) const;
int childCount() const;
// 类型判断
NodeType nodeType() const;
virtual bool isCloudNode() const { return false; }
virtual bool isGroup() const { return false; }
// 元数据
QString name() const;
void setName(const QString& name);
QString path() const; // 全路径,如 /点云A/直通滤波/聚类#0
OpInfo operationInfo() const;
void setOperation(const OpRecord& op);
OpRecord operationRecord() const;
// 显示属性
DisplayProperty display() const;
void setDisplay(const DisplayProperty& prop);
bool visible() const;
void setVisible(bool v);
float pointSize() const;
void setPointSize(float s);
// 参考元素(Phase 4 使用)
QList<GeoRef> geoRefs() const;
void addGeoRef(const GeoRef& ref);
void removeGeoRef(int index);
void clearGeoRefs();
// 状态
bool isProcessing() const;
void setProcessing(bool flag);
QString statusMessage() const;
void setStatusMessage(const QString& msg);
// 变换矩阵(预留,§13.2 #12)
Eigen::Matrix4f storedTransform() const;
void setStoredTransform(const Eigen::Matrix4f& t);
protected:
quint64 m_id;
NodeType m_type;
QString m_name;
SceneNode* m_parent;
QList<SceneNode*> m_children;
OpRecord m_operation;
DisplayProperty m_display;
QList<GeoRef> m_geoRefs;
bool m_processing = false;
QString m_statusMsg;
Eigen::Matrix4f m_storedTransform = Eigen::Matrix4f::Identity();
};
4.9 点云节点
cpp
struct BoundingBox {
float x_min, x_max;
float y_min, y_max;
float z_min, z_max;
float width() const { return x_max - x_min; }
float depth() const { return y_max - y_min; }
float height() const { return z_max - z_min; }
float volume() const { return width() * depth() * height(); }
};
class CloudNode : public SceneNode {
public:
explicit CloudNode(SceneNode* parent = nullptr);
~CloudNode() override;
NodeType nodeType() const override { return NodeType::Cloud; }
bool isCloudNode() const override { return true; }
// 点云数据(当前版本固定为 pcl::PointXYZ)
// ★ 预留:后续改为模板或抽象接口,支持 XYZRGB、XYZINormal 等
pcl::PointCloud<pcl::PointXYZ>::Ptr cloud() const;
void setCloud(const pcl::PointCloud<pcl::PointXYZ>::Ptr& cloud);
int pointCount() const;
// AABB(调用 computeBounds() 后填充)
BoundingBox bbox() const;
void computeBounds();
// 标量场管理(★ 新增,参考 CloudCompare)
QList<ScalarField*> scalarFields() const;
void addScalarField(ScalarField* field); // 取得所有权
void removeScalarField(int index);
void setActiveScalarField(int index); // -1 表示无活动标量场
int activeScalarFieldIndex() const;
ScalarField* activeScalarField() const;
// 大坐标精度(预留,§13.2 #8)
Eigen::Vector3f globalShift() const;
void setGlobalShift(const Eigen::Vector3f& shift);
float globalScale() const;
void setGlobalScale(float scale);
private:
pcl::PointCloud<pcl::PointXYZ>::Ptr m_cloud;
BoundingBox m_bbox;
bool m_bboxValid = false;
QList<ScalarField*> m_scalarFields;
int m_activeScalarField = -1;
Eigen::Vector3f m_globalShift = Eigen::Vector3f::Zero();
float m_globalScale = 1.0f;
};
5. UI 设计
5.1 整体布局
┌─────────────────────────────────────────────────────────┐
│ Menu Bar (文件 编辑 视图 工具 ▾ 帮助) │
├───────────┬─────────────────────────────────────────────┤
│ │ VTK 渲染窗口 (PCLVisualizer) │
│ │ │
│ 场景树 │ ┌─ 坐标轴 ─┐ ┌─ 信息面板 ─┐ │
│ (QTree) │ │ 3D 视口 │ │ 点数量: xxx│ │
│ │ │ │ │ 边界: ... │ │
│ │ │ │ │ 操作链: ...│ │
│ │ └──────────┘ └────────────┘ │
│ │ │
├───────────┤ │
│ │ │
│ 属性面板 │ │
│ (选中节点 │ │
│ 时显示) │ │
│ │ │
├───────────┴─────────────────────────────────────────────┤
│ 状态栏: 就绪 / 操作中: ICP 配准 进度 45% / 错误信息 │
└─────────────────────────────────────────────────────────┘
5.2 场景树 (QTreeWidget)
每个 TreeWidgetItem 显示:
- 图标:点云图标 / 处理结果图标 / 参考元素图标
- 名称:节点名称
- 第 2 列显示点数量(仅 CloudNode)
交互:
| 操作 | 行为 |
|---|---|
| 左键单击 | 选中节点 → 更新 m_lastClicked,属性面板刷新 |
| Ctrl + 单击 | 多选切换 |
| Shift + 单击 | 范围选择 |
| 双击 | 居中视图到该点云 |
| 右键单击 | 弹出上下文菜单 |
| 复选框 (Qt::CheckStateRole) | 控制显示/隐藏 |
5.3 上下文菜单
单选节点时(CloudNode):
┌─────────────────────────────┐
│ ▶ 仅显示本节点 │
│ 👁 显示/隐藏 │
├─────────────────────────────┤
│ 居中视图 │
├─────────────────────────────┤
│ ── 滤波 ── │
│ ▸ 体素下采样 │
│ ▸ 统计滤波 │
│ ▸ 直通滤波 │
│ ▸ 最大连通区域 │
├─────────────────────────────┤
│ ── 裁剪 ── │
│ Polygon 裁剪 │
├─────────────────────────────┤
│ ── 变换 ── │
│ 缩放(单位统一) │
├─────────────────────────────┤
│ ── 聚类 ── │
│ 欧几里得聚类 │
├─────────────────────────────┤
│ ── 配准 ── │
│ ICP 配准 │
├─────────────────────────────┤
│ ── 参考元素 ── │
│ 添加参考点 │
│ 添加参考线 │
│ 添加参考面 │
│ 清理所有参考元素 │
├─────────────────────────────┤
│ 导出点云 (.pcd) │
│ 删除节点 │
└─────────────────────────────┘
多选节点时(Ctrl 多选):
┌─────────────────────────────┐
│ 同时显示选中节点 │
│ 同时隐藏选中节点 │
├─────────────────────────────┤
│ 联合标定 │
├─────────────────────────────┤
│ 删除所有选中节点 │
└─────────────────────────────┘
联合标定交互(核心多选功能):
1. Ctrl 多选多个点云节点(需 ≥2 个)
2. 右键 → 联合标定
3. 弹出标定对话框:
┌─── 联合标定 ────────────────┐
│ 设计坐标系(目标/参考): │
│ [▼ 选择 ▾] │
│ 点云A │
│ 点云B │
│ 点云A/降采样结果 │
└─────────────────────────────┘
│ 测量坐标系(源/待配准): │
│ ☑ 点云A/降采样结果 │
│ ☑ 点云B │
│ ☐ 点云A │
└─────────────────────────────┘
│ ICP 参数: │
│ 最大对应距离: [5.0] │
│ 最大迭代次数: [50] │
│ │
│ [ 取消 ] [ 开始标定 ] │
└──────────────────────────────┘
4. 用户选择设计坐标系(一个),勾选测量坐标系(一个或多个)
5. 点击"开始标定" → 后台依次将每个测量坐标系 ICP 配准到设计坐标系
6. 标定完成后显示结果表格:
┌─── 标定结果 ────────────────┐
│ 源节点 │ 目标节点 │ RMS │ 状态 │
├─────────────┼──────────────┼────────┼────────┤
│ 点云B │ 点云A │ 0.003 │ 完成 │
│ 点云A/降采样│ 点云A │ 0.001 │ 完成 │
└─────────────┴──────────────┴────────┴────────┘
│ [ 保存变换 ] [ 关闭 ] │
└──────────────────────────────┘
7. 每条结果行显示:源、目标、RMS 残差、配准状态
8. 点"保存变换"将所有结果存入 TransformRegistry
注意:多选时不提供 Polygon 裁剪,如需裁剪请逐次处理。
有 GeoRef 被选中时:
┌─────────────────────────────┐
│ 显示/隐藏 │
├─────────────────────────────┤
│ 修改颜色 │
│ 修改大小 │
├─────────────────────────────┤
│ ── 计算 ── │
│ ▸ 多点拟合直线 │
│ ▸ 多点拟合平面 │
│ ▸ 点线距离 │
│ ▸ 点面距离 │
├─────────────────────────────┤
│ 删除 │
└─────────────────────────────┘
5.4 坐标系处理工具(独立模块)
入口 :菜单栏 工具 ▸ 坐标系处理
功能:输入一组坐标变换参数,以不同表示方式实时预览,并在 VTK 视口中以坐标轴图标显示。
UI 布局:
┌─── 坐标系处理 ─────────────────┐
│ 变换表示(四选一): │
│ ◉ 欧拉角 (XYZ) ○ 轴角 │
│ ○ 四元数 ○ 旋转矩阵 │
├───────────────────────────────┤
│ 欧拉角: │
│ Rx: [ 0.00 ] deg │
│ Ry: [ 0.00 ] deg │
│ Rz: [ 0.00 ] deg │
├───────────────────────────────┤
│ 轴角: │
│ 轴: [0.00, 0.00, 1.00] │
│ 角: [ 0.00 ] deg │
├───────────────────────────────┤
│ 四元数: │
│ w: [1.000] x: [0.000] │
│ y: [0.000] z: [0.000] │
├───────────────────────────────┤
│ 旋转矩阵: │
│ [ 1.000 0.000 0.000 ] │
│ [ 0.000 1.000 0.000 ] │
│ [ 0.000 0.000 1.000 ] │
├───────────────────────────────┤
│ 平移: │
│ Tx: [ 0.000 ] Ty: [ 0.000 ] │
│ Tz: [ 0.000 ] │
├───────────────────────────────┤
│ 显示选项: │
│ ☑ 在视口显示坐标系轴 │
│ 轴长: [ 10.0 ] │
│ 原点: [ 0.0, 0.0, 0.0 ] │
├───────────────────────────────┤
│ 4×4 齐次变换矩阵: │
│ ┌───────────────────────┐ │
│ │ 1.000 0.000 0.000 0.000││
│ │ 0.000 1.000 0.000 0.000││
│ │ 0.000 0.000 1.000 0.000││
│ │ 0.000 0.000 0.000 1.000││
│ └───────────────────────┘ │
├───────────────────────────────┤
│ [ 应用到当前点云 ] [ 复制 ] │
└───────────────────────────────┘
交互规则:
- 切换表示方式时,自动同步更新其他表示方式的数值
- 欧拉角默认 XYZ 顺序(内置固定顺序,不改)
- 修改任意值 → 自动重算所有其他表示 + 4×4 矩阵预览
- 勾选"在视口显示坐标系轴" → VTK 中实时显示坐标系轴图标(红=X、绿=Y、蓝=Z)
- 点击"应用到当前点云" → 将当前变换作为 Transform 操作,挂载到当前场景树节点下(调用
OperationRouter::applyTransform) - 点击"复制" → 将 4×4 矩阵复制到系统剪贴板(制表符分隔,方便粘贴到 Excel/Matlab)
数据类型:
cpp
struct CoordTransform {
// 平移
Eigen::Vector3f translation;
// 旋转(四表示法,内部只存一个,其余实时转换)
Eigen::Vector3f eulerAngle; // XYZ 欧拉角,弧度
Eigen::Vector3f axisAngleAxis; // 轴角 旋转轴(单位向量)
float axisAngleAngle; // 轴角 角度(弧度)
Eigen::Quaternionf quaternion; // 四元数
Eigen::Matrix3f rotationMatrix; // 3×3 旋转矩阵
// 4×4 齐次变换
Eigen::Matrix4f homogeneousMatrix() const;
// 单位切换
bool eulerInDegrees; // 界面显示用角度,内部存弧度
};
5.5 属性面板
选中节点后显示:
┌─── 节点属性 ───────────────┐
│ 名称: [直通滤波_X] │
│ 类型: 处理结果 │
│ 操作: 体素下采样 │
│ 点数量: 125,430 │
│ 父节点: /点云A │
├───────────────────────────┤
│ 显示属性 │
│ 点大小: [====|====] 2.0 │
│ 透明度: [====|====] 0.8 │
│ 颜色: [色块] │
│ 着色模式: [唯一颜色 ▾] │
│ 显示 BBox: [✓] │
├───────────────────────────┤
│ 标量场 (2) │
│ [▼ height ▾] min:0 max:5│
│ · distance │
│ [+ 添加标量场] │
├───────────────────────────┤
│ BBox 信息 │
│ X: [1.000, 11.000] 宽 10.000 │
│ Y: [0.500, 4.000] 深 3.500 │
│ Z: [1.000, 5.000] 高 4.000 │
│ 体积: 140.000 m³ │
├───────────────────────────┤
│ 操作链 │
│ /点云A │
│ └→ 直通滤波_X(1~11) │
│ └→ 当前节点 │
├───────────────────────────┤
│ 参考元素 (3) │
│ · 参考点 p0 (颜色) │
│ · 参考线 L1 (颜色) │
│ · 拟合平面 P1 (颜色) │
└───────────────────────────┘
5.6 状态监控
操作状态显示在状态栏 ,使用 QProgressBar + QLabel:
[状态栏]
├─ QLabel: "操作中: ICP 配准 源:点云A/降采样 → 目标:点云B"
├─ QProgressBar: [████████░░░░░░░░░░░░] 40%
└─ QPushButton: "取消"
方案 :OperationRouter 通过信号 operationProgress 向主线程报告进度(0~100)。主线程的 SceneTreeManager 或状态栏组件监听此信号,更新 QProgressBar。后台操作不阻塞 UI,用户可继续浏览场景树。
6. 核心操作路由设计
6.1 选中节点管理
选择模型 :以 m_selectedNodes 为唯一数据源,单选是多选的特例(列表长度为 1):
QTreeWidget 信号 SceneTreeManager 状态
────────────────────────────────────────────────────────────────
左键单击节点 A → m_selectedNodes = {A}
m_lastClicked = A
发射 selectionChanged(A)
Ctrl+单击节点 B → m_selectedNodes = {A, B}
m_lastClicked = B
发射 selectionChanged(B) // 属性面板刷新为最后点击的
Shift+单击节点 C → m_selectedNodes = {A, B, C}
m_lastClicked = C
发射 multiSelectionChanged({A,B,C})
双击节点 A → m_selectedNodes = {A}
m_lastClicked = A
相机居中到 A 的点云
无选中(取消所有选择) → m_selectedNodes = {}
m_lastClicked = nullptr
属性面板显示"未选择"
cpp
class SceneTreeManager {
public:
static SceneTreeManager& instance();
// 选中管理(以 m_selectedNodes 为唯一真相)
SceneNode* currentNode() const; // = m_lastClicked(最后点击的)
QList<SceneNode*> selectedNodes() const; // = m_selectedNodes(所有选中的)
bool isMultiSelected() const; // = m_selectedNodes.size() > 1
SceneNode* firstSelected() const; // = m_selectedNodes.first()
QList<CloudNode*> selectedClouds() const; // 过滤出所有 CloudNode 类型
void setCurrentNode(SceneNode* node); // 单选:内部清空列表后 setMultiNodes({node})
void setMultiNodes(const QList<SceneNode*>& nodes);// 多选:直接赋值
void clearSelection();
// 树操作
CloudNode* addCloud(const QString& name,
const pcl::PointCloud<pcl::PointXYZ>::Ptr& cloud,
SceneNode* parent = nullptr);
CloudNode* addResultNode(CloudNode* parent,
const QString& name,
const pcl::PointCloud<pcl::PointXYZ>::Ptr& cloud,
const OpRecord& op);
bool removeNode(SceneNode* node); // 递归删除子节点
// 节点查找
CloudNode* findNodeByPath(const QString& path) const;
// 变换管理
TransformRegistry& transformRegistry();
// VTK 渲染
void refreshVTKView(); // 根据可见节点更新 VTK
void addCloudToVTK(CloudNode* node);
void removeCloudFromVTK(CloudNode* node);
void updateCloudInVTK(CloudNode* node);
signals:
void selectionChanged(SceneNode* node); // 选中变化(单选或多选都发此信号)
void nodeAdded(SceneNode* parent, CloudNode* child);
void nodeRemoved(SceneNode* node);
void operationStarted(const QString& opName);
void operationProgress(const QString& opName, int progress);
void operationFinished(const QString& opName, bool success);
private:
SceneNode* m_root;
SceneNode* m_lastClicked; // 最后点击的节点(用于单选和属性面板)
QList<SceneNode*> m_selectedNodes; // 当前所有选中的节点(唯一数据源)
TransformRegistry m_transformRegistry;
};
QTreeWidget 信号对接 (SceneTreeWidget 内部实现):
cpp
// 在 SceneTreeWidget 中:
connect(this, &QTreeWidget::itemSelectionChanged, this, [this]() {
auto items = selectedItems(); // QTreeWidget 自己管理多选
QList<SceneNode*> nodes;
for (auto* item : items)
nodes.append(static_cast<SceneNode*>(item->data(0, Qt::UserRole).value<quintptr>()));
SceneTreeManager::instance().setMultiNodes(nodes);
});
设计要点:
QTreeWidget负责管理"哪些 item 被勾选中"(Qt 内置多选逻辑)SceneTreeManager是全局单例,存储当前选中状态,业务逻辑通过它获取节点m_lastClicked只用于属性面板显示,不参与多选逻辑- 需要 CloudNode 时调用
selectedClouds()自动过滤,不维护单独的QList<CloudNode*>副本
6.2 操作路由逻辑
所有操作入口统一从场景树出发,不再绑定具体按钮到具体点云。
Axis 枚举(直通滤波用):
cpp
enum class Axis { X, Y, Z };
CalibrationResult(独立结构体,不嵌套在任何类内部):
cpp
// 定义在 operation_context.h 中,供 OperationRouter 和标定对话框共用
struct CalibrationResult {
CloudNode* source; // 测量节点
CloudNode* target; // 设计节点
Eigen::Matrix4f matrix; // 配准变换矩阵
double rms; // RMS 残差
bool success; // 是否成功
QString errorMsg; // 失败原因
};
OperationRouter(QObject 基类,非全 static):
改为 QObject 继承,用信号槽驱动操作执行,便于后续扩展为插件机制和单元测试。
cpp
class OperationRouter : public QObject {
Q_OBJECT
public:
explicit OperationRouter(QObject* parent = nullptr);
// 滤波类操作:作用于选中单节点,结果挂为子节点
void applyVoxelDownsample(CloudNode* target, double leafSize);
void applyStatisticalFilter(CloudNode* target, int neighbors, double stdDev);
void applyPassThrough(CloudNode* target, Axis axis, float min, float max);
void applyMaxConnectedRegion(CloudNode* target);
// 聚类操作:结果产生多个子节点
void applyEuclideanCluster(CloudNode* target, double tolerance, int minPts, int maxPts);
// 变换操作:作用于选中单节点
void applyTransform(CloudNode* target, const Eigen::Matrix4f& transform);
// 缩放操作:作用于选中单节点,按指定倍率缩放 XYZ 坐标
void applyScale(CloudNode* target, const Eigen::Vector3f& scaleFactor);
// Polygon 裁剪:在 VTK 视口上交互式绘制多边形,裁剪选中节点
void applyPolygonCrop(CloudNode* target,
const QVector<QVector3D>& polygon,
bool keepInside);
// ICP 单节点配准:将 source 点云配准到 target 点云,结果挂载到 source 下
void applyICP(CloudNode* source, CloudNode* target,
double maxCorrDist, int maxIter);
// 联合标定:将多个测量坐标系配准到一个设计坐标系,结果通过信号返回
void applyJointCalibration(CloudNode* design,
const QList<CloudNode*>& measurements,
double maxCorrDist, int maxIter);
signals:
// 操作生命周期信号
void operationStarted(const QString& opName);
void operationProgress(const QString& opName, int progress);
void operationFinished(const QString& opName, bool success);
void calibrationFinished(const QList<CalibrationResult>& results);
// 数据变更信号(对话框监听后调用 SceneTreeManager 添加节点)
void cloudCreated(CloudNode* parentNode,
const QString& childName,
const pcl::PointCloud<pcl::PointXYZ>::Ptr& cloud,
const OpRecord& op);
};
扩展性预留 :后续可通过
OperationRegistry::registerOperation(name, factory)动态注册新操作,而不需要修改 OperationRouter 本身。插件 DLL 只需注册自己的操作工厂函数。
操作执行流程(以直通滤波为例):
用户右键 → 滤波 → X 向直通滤波 → 弹出参数对话框
→ 用户输入 min=1, max=11 → 确认
→ 获取 currentNode() → CloudNode A
→ 对话框调用 router.applyPassThrough(A, Axis::X, 1, 11)
→ 发射 signal: operationStarted("直通滤波_X_1~11")
→ 后台线程:
1. 报告 progress(0)
2. 调用 alg_cloud_common::passThrough(A->cloud(), axis, 1, 11)
3. 报告 progress(100)
→ 发射 signal: operationFinished("直通滤波_X_1~11", true)
→ 发射 signal: cloudCreated(A, "直通滤波_X_1~11", result, {OpInfo{Filter, "直通滤波_X"}, {min:1, max:11}})
→ 对话框监听 cloudCreated → 调用 SceneTreeManager::addResultNode():
1. 创建 CloudNode B,parent = A
2. B->setCloud(result)
3. 更新场景树 UI,展开 A,选中 B
4. 刷新 VTK 渲染
信号流设计要点 :OperationRouter 不直接操作 SceneTreeManager,通过信号解耦。
对话框/主窗口监听
cloudCreated信号,负责调用SceneTreeManager::addResultNode()完成节点挂载。
6.3 Polygon 裁剪交互(所见即所得)
核心理念:当前 VTK 视口看到什么视角,就在那个视角的 2D 投影面上画多边形裁剪。所见即所得,不指定固定 XY/XZ/ZY 投影面,而是直接用当前相机视角。
绘制方式(参考 CloudCompare):
1. 用户右键 → 裁剪 → Polygon 裁剪
2. 进入 Polygon 绘制模式,VTK 窗口鼠标变为十字光标
3. 依次单击左键添加顶点 → 每点之间用红色线段连接,实时显示在 VTK 视口上
4. 双击左键闭合多边形 → 自动从最后一个点连线到第一个点
5. 弹出裁剪对话框:
┌─── Polygon 裁剪 ────────────┐
│ 裁剪模式: ◉ 保留内部 ○ 外部 │
│ 顶点数: 6 │
│ 当前视角: 俯视 │
│ │
│ [ 取消 ] [ 保存 ] │
└──────────────────────────────┘
6. 用户确认 → 执行裁剪 → 结果挂载为当前节点子节点
实现要点:
- 用户在 VTK 视口上点的是 2D 屏幕坐标(像素位置)
- 将当前视口中可见的所有点云 3D 点 投影到当前相机的 2D 屏幕平面 (用 VTK 的
vtkRenderWindow::WorldToDisplay/vtkCamera变换矩阵) - 在 2D 屏幕坐标系下用射线法(ray casting)判断每个投影点是否在 Polygon 内部
- 保留内部(或外部)的点 → 这些点在 3D 空间的原坐标保留(不修改 3D 坐标,只是筛选)
- Polygon 轮廓在 VTK 视口上实时绘制为红色虚线(用
vtkActor2D或vtkPolyData) - 绘制期间不影响相机旋转,用户可以重新调整视角后再画(但裁剪基于最终 Polygon 所在视角)
关键公式:
camera_3D_point → WorldToDisplay(camera_3D_point) → (screen_x, screen_y, depth)
for each cloud point P:
(sx, sy, _) = WorldToDisplay(P)
if ray_casting(polygon, (sx, sy)) == inside:
根据 keepInside 决定保留或剔除
这样用户旋转到任意角度,画个框就能把"看到的那一面"的点筛出来,完全符合直觉。
6.4 ICP 配准交互
1. 用户右键 → 配准 → ICP 配准
2. 弹出 ICP 对话框:
┌─── ICP 配准 ────────────────┐
│ 源点云: [当前节点名称] │
│ 目标点云: [▼ 选择节点 ▾] │
│ ├─ 点云A │
│ ├─ 点云B │
│ ├─ 点云A/降采样结果 │
│ └─ ... │
│ │
│ 最大对应距离: [5.0] │
│ 最大迭代次数: [50] │
│ RMS 残差: [---] │
│ │
│ [ 取消 ] [ 开始配准 ] │
└──────────────────────────────┘
3. 用户选择目标点云 → 点击"开始配准"
4. 后台执行 ICP(使用 PCL 自带 `pcl::IterativeClosestPoint`)→ 状态栏显示进度
5. 配准完成后:
- 显示变换矩阵和 RMS 残差
- 询问是否保存变换到 TransformRegistry(输入名称/备注)
- 将配准后的点云作为子节点挂载到当前节点下
7. 坐标系变换管理
7.1 设计
变换信息统一由 TransformRegistry 管理,支持持久化:
json
{
"version": 1,
"transforms": [
{
"name": "Car_To_World",
"source": "/点云A/降采样",
"target": "/点云B",
"matrix": [
[0.998, -0.062, 0.012, 1.5],
[0.061, 0.997, -0.045, 0.3],
[-0.014, 0.044, 0.999, -0.2],
[0, 0, 0, 1]
],
"timestamp": 1716528000.0,
"remark": "ICP配准结果, 残差=0.003"
}
]
}
7.2 交互
- 保存变换:ICP 或手动配准完成后,弹出对话框输入名称和备注,存入注册表
- 加载变换:属性面板有"变换"区域,下拉列表显示已存变换,选择后预览/应用
- 快速应用:右键 → 坐标系变换 → 应用已保存变换 → 选择 → 点云自动变换
8. 参考元素系统
8.1 拾取方式
| 方式 | 操作 |
|---|---|
| 单击拾取点 | 进入拾取模式 → 双击 VTK 视口 → 记录 3D 坐标 → 创建 GeoRef(Point) |
| Polygon 拾取 | 进入 Polygon 绘制模式 → 在 VTK 视口上点击多个点 → 闭合多边形 → 投影到点云 → 记录范围内的所有点 |
| 手动输入 | 属性面板输入 XYZ 坐标 → 创建参考点 |
8.2 拟合计算
选 2+ 个参考点 → 右键 → 计算 → 拟合直线
→ 最小二乘法拟合直线(方向 + 原点)
→ 创建 GeoRef(Line) 挂在当前节点上
选 3+ 个参考点 → 右键 → 计算 → 拟合平面
→ SVD 法拟合平面(法向 + d)
→ 创建 GeoRef(Plane) 挂在当前节点上
选 Polygon 区域内的点云 → 右键 → 计算 → 拟合平面
→ 提取区域内点 → RANSAC 拟合平面(抗离群点)
→ 创建 GeoRef(Plane) 挂在当前节点上
8.3 跨节点计算
Ctrl 选 节点A 上的参考点 P1
Ctrl 选 节点B 上的参考点 P2
→ 右键 → 计算 → 点间距
→ 显示 |P1-P2| 距离
9. 文件与持久化
9.1 滤波参数配置文件(FilterConfig 类管理)
所有滤波/聚类操作的参数通过 FilterConfig 类读写 filter_params.ini,遵循"界面与文件双向同步"原则:
cpp
class FilterConfig {
public:
static FilterConfig& instance();
// 通用接口:获取/设置参数
double get(const QString& section, const QString& key, double defaultValue) const;
void set(const QString& section, const QString& key, double value);
QString getString(const QString& section, const QString& key, const QString& defaultValue) const;
void setString(const QString& section, const QString& key, const QString& value);
// 加载/保存
void load(); // 程序启动时调用,从 filter_params.ini 加载
void save(); // 用户点"保存为默认"时调用
private:
QSettings m_settings; // QSettings with INI format
};
INI 文件格式:
ini
; filter_params.ini
[general]
version = 1
[voxel]
leaf_size = 100.0
[statistical]
neighbors = 1000
std_dev = 1.0
[pass_through]
x_min = 1.0
x_max = 11.0
y_min = 0.5
y_max = 4.0
z_min = 1.0
z_max = 5.0
[euclidean_cluster]
tolerance = 0.5
min_pts = 100
max_pts = 25000
[scale]
x_factor = 1.0
y_factor = 1.0
z_factor = 1.0
preset = custom # custom / m2cm / m2mm / cm2mm / mm2cm
[icp]
max_correspondence_distance = 5.0
max_iterations = 50
ransac_iterations = 0
ransac_outlier_reject = 0.05
[polygon]
keep_inside = true
同步机制:
- 启动加载 :程序启动时
FilterConfig::instance().load(),所有滤波对话框的默认值来自此文件 - 对话框修改:用户在滤波对话框中修改参数 → 仅影响本次操作
- 保存参数 :对话框有"保存为默认"复选框,勾选时将当前参数写回
filter_params.ini - 下次启动:自动加载上次保存的参数值
扩展性 :新增操作参数只需在 INI 文件中新增 section/key,无需修改 FilterConfig 类本身。
9.2 变换注册表持久化
ICP 配准等变换结果通过 TransformRegistry 管理,保存为 transforms.json(格式见 7.1 节)。
9.3 当前阶段不做
- 磁盘缓存(点云数据量大时暂不缓存)
- 撤销/重做(Ctrl+Z)
- 项目文件序列化(场景树整体保存,Phase 4 考虑)
10. 迁移方案
10.1 从现有架构迁移
现有模块保留,新增以下模块:
| 新增文件 | 职责 |
|---|---|
scene_tree.h |
SceneNode, CloudNode, BoundingBox, ScalarField, ColorScale, GeoRef, NodeType 等数据结构定义 |
scene_tree_widget.h/.cpp |
场景树 QTreeWidget 封装 |
scene_tree_manager.h/.cpp |
场景树全局管理器 |
operation_router.h/.cpp |
OperationRouter(QObject 基类,信号驱动) |
operation_context.h |
CalibrationResult 独立结构体定义 |
transform_registry.h/.cpp |
变换注册表(含 JSON 序列化,version 字段) |
property_panel.h/.cpp |
属性面板 Widget |
context_menu.h/.cpp |
上下文菜单构建器 |
filter_config.h/.cpp |
FilterConfig 类(读写 filter_params.ini,含 version 字段) |
polygon_crop_dialog.h/.cpp |
Polygon 裁剪交互(VTK 上绘制 + 对话框) |
icp_dialog.h/.cpp |
ICP 配准对话框 |
joint_calibration_dialog.h/.cpp |
联合标定对话框(选择设计/测量坐标系 + 结果表格) |
coord_transform_dialog.h/.cpp |
坐标系处理工具对话框(四表示法转换 + VTK 预览) |
10.2 修改现有文件
| 文件 | 修改内容 |
|---|---|
cloud_anaylzer.h/.cpp |
移除 m_cloud / m_transformed_cloud,改为通过 SceneTreeManager 管理;UI 加入 QTreeWidget + 属性面板 |
cloud_anaylzer.ui |
添加场景树面板、属性面板 |
| 现有槽函数 | 改为调用 OperationRouter,如 applyPassThrough(Axis::X, 1, 11) |
pcl_pointpicker.h/.cpp |
改为向场景树添加 GeoRef 而非返回裸坐标 |
10.3 实施步骤
Phase 1 --- 基础框架(优先,当前版本核心)
- 实现
SceneNode+CloudNode数据结构 - 实现
SceneTreeWidget(显示、选中、右键菜单) - 实现
SceneTreeManager(添加/删除/选中管理) - 改造主窗口布局:嵌入场景树
- 实现节点显示/隐藏联动 VTK 渲染
- 迁移点云加载 → 不再用
m_cloud,改为addCloud() - 实现右键菜单:显示/隐藏、删除、居中视图
- 坐标系处理工具:四表示法转换 + VTK 坐标系轴预览(独立于场景树,可直接开发)
Phase 2 --- 操作链 (当前版本核心)
-
改造现有操作函数 →
OperationRouter(滤波、变换、聚类) -
操作后结果作为子节点挂载
-
实现属性面板(显示节点信息 + 显示属性编辑)
-
实现状态栏操作状态提示
Phase 3 --- Polygon + 联合标定 (当前版本核心)
-
Polygon 交互式绘制(VTK 点击拾取、实时预览、双击闭合)
-
Polygon 裁剪对话框(内部/外部选择、保存按钮)
-
单节点 ICP 配准对话框(combobox 选目标、参数配置、结果展示)
-
联合标定 对话框:选择设计/测量坐标系,批量 ICP 配准
-
标定结果表格组件(QTableWidget),显示源/目标/RMS/状态
-
裁剪/ICP/标定结果挂载为子节点
Phase 4 --- 参考元素 (后续迭代)
-
参考元素拾取与可视化
-
几何拟合(平面、直线)
-
TransformRegistry 持久化
11. 技术选型备注
| 决策 | 选择 | 理由 |
|---|---|---|
| 树控件 | QTreeWidget |
比 QTreeView 更简单,当前项目已有 Qt 使用经验,节点数量不大时性能无压力 |
| VTK 渲染 | 保持 PCLVisualizer |
不改动 3D 引擎,只在可见性控制上对接场景树 |
| 进度报告 | QTimer + std::atomic 轮询 |
简单可靠,不引入额外并发框架 |
| 变换存储 | JSON 文件(含 version) | 易读易写,Qt 内置 QJsonDocument 支持 |
| 滤波配置 | FilterConfig 类 + INI(含 version) |
get/set 通用接口,新增参数无需改代码 |
| 编码 | 纯 ASCII 标识符 + 英文 UI | 避免 GBK/UTF-8 编码问题,运行时通过 QObject::tr() 做中文本地化 |
| 缓存/撤销 | 暂不实现 | 先做一版能用的,后续按需添加 |
| 操作路由 | QObject 基类 + 信号槽 |
便于扩展插件机制和单元测试 |
12. 已确认的技术决策
| 决策 | 结论 |
|---|---|
| Polygon 投影方式 | 所见即所得:基于当前相机视角的 2D 屏幕投影,不指定固定平面 |
| Polygon 裁剪交互 | 左键依次点顶点 → 双击闭合 → 弹出对话框选内/外 → 点"保存"确认 |
| ICP 算法 | PCL 自带 pcl::IterativeClosestPoint,暂不提取到算法库 |
| 滤波参数 | FilterConfig 类管理 filter_params.ini(含 [general] version = 1),get/set 通用接口,界面修改后点"保存为默认"写回,启动时自动加载 |
| 缩放操作 | 支持 XYZ 三向独立缩放,预设常用单位转换(m→cm、m→mm、cm→mm、mm→cm) |
| BBox | 选中节点自动在 VTK 视口显示 AABB 线框 + 属性面板实时显示 BBox 尺寸/体积;预留 globalShift/globalScale 处理大坐标精度 |
| 多选联合操作 | 联合标定:选一个设计坐标系,勾选多个测量坐标系 → 批量 ICP 配准 → 结果表格展示 |
| 多选 Polygon/ICP | 不提供(裁剪仅限单选) |
| 磁盘缓存/撤销 | 暂不实现,先做一版能用的 |
| 参考元素 | Phase 4 才实现,Phase 1~3 先跳过 |
| 点云类型 | 当前版本固定 pcl::PointXYZ,CloudNode 注释标注预留抽象层(支持 XYZRGB 等) |
| 着色机制 | 三模式:UniqueColor(固定色)/ ScalarField(标量场+颜色标尺)/ PerPointColor(预留),取代孤立的 color_by_height |
| 操作类型 | OpCategory 大类 + OpInfo(字符串名),便于插件动态注册 |
| 操作路由 | OperationRouter 为 QObject 基类,信号驱动,非全 static |
| 节点唯一 ID | SceneNode 构造时由 UniqueIdGenerator(QAtomicInteger)分配 quint64 id() |
| 配置版本 | filter_params.ini 和 transforms.json 均含 version 字段,支持跨版本兼容 |
13. 架构评审 --- 与 CloudCompare 对比发现的疏漏
本节基于 CloudCompare(开源点云处理标杆工具)的架构分析,列出当前设计中遗漏或不合理 的设计点,按重要性 排序。
所有"本期不做"的项标记为
⊘,会在后续 Phase 补充;"立即修复"的标记为★,应在本期设计阶段就解决。
13.1 立即修复(设计阶段就应解决)✅ 已全部修正
| # | 问题 | 修正方案 | 修正位置 |
|---|---|---|---|
| 1 | ★ 点云类型硬编码为 pcl::PointXYZ |
CloudNode 保留 PointXYZ,但注释标注预留抽象层;DisplayProperty 用 ColorMode 替代 color_by_height;属性面板支持 ScalarField 切换 | 4.9 CloudNode |
| 2 | ★ 缺少标量场(Scalar Field)机制 |
新增 §4.1 ScalarField + ColorScale 设计;CloudNode 增加标量场管理 API;属性面板增加标量场区域 | 4.1, 4.2, 4.9 |
| 3 | ★ CalibrationResult 嵌套在 OperationRouter 内部 |
移出到 operation_context.h 作为独立结构体;OperationRouter 通过信号 calibrationFinished 返回 |
6.2 |
| 4 | ★ INI/JSON 配置缺少版本字段 |
filter_params.ini 增加 [general] version = 1;transforms.json 增加 "version": 1 |
9.1, 7.1 |
| 5 | ★ 缺少唯一 ID 机制 |
SceneNode 增加 quint64 id() 成员 + UniqueIdGenerator 全局计数器(QAtomicInteger 线程安全) |
4.8 |
| 6 | ★ 操作路由用 static 类 |
OperationRouter 改为 QObject 继承,用信号槽驱动操作执行;预留 OperationRegistry 插件注册机制 |
6.2 |
13.2 本期架构预留(接口设计时考虑,本期不实现)
| # | 预留 | 说明 | 如何预留 |
|---|---|---|---|
| 7 | ⊘ OBB(方向包围盒) |
当前只有 AABB(轴对齐包围盒)。CloudCompare 对旋转后的点云可显示 OBB | SceneNode 预留 OrientedBoundingBox 可选数据,DisplayProperty 预留 show_OBB |
| 8 | ⊘ 大坐标精度处理(Global Shift) |
当点云坐标值很大时(如 UTM 坐标 X=600000),float 精度不够导致渲染抖动 | CloudNode 预留 shift 机制接口,setCloud 时自动检测坐标范围 |
| 9 | ⊘ 多格式导入/导出 |
当前只提到 .pcd。常见格式:.las、.ply、.e57、.stl |
exportCloud 参数带格式枚举,不硬编码后缀 |
| 10 | ⊘ 分组/文件夹节点 |
场景树只支持"点云 → 结果",缺少纯组织性质的分组节点 | SceneNode 支持 isGroup() 类型,分组节点无几何数据但可包含子节点 |
| 11 | ⊘ 网格/曲线实体 |
CloudCompare 支持 Mesh、Polyline、2D Entity 等多种实体 | NodeType 已有 Mesh/Polyline 枚举值,后续扩展 MeshNode/PolylineNode 继承 SceneNode 即可 |
| 12 | ⊘ 变换的"应用"与"存储"分离 |
当前 ICP 结果直接挂子节点,但存储的变换矩阵 和实际变换点云是两个概念 | SceneNode 预留 storedTransform 属性(不修改点云数据,仅记录变换);右键"应用变换"才真正修改点云 |
| 13 | ⊘ 命令历史栈(Undo/Redo) |
操作链是只读的追溯,不是可逆的命令历史 | OpRecord 预留 inverse() 接口,后续接 QUndoStack |
13.3 需要补充的设计细节 ✅ 已补充
| # | 细节 | 修正方案 | 修正位置 |
|---|---|---|---|
| 14 | 节点类型系统 | 已定义 NodeType 枚举 + nodeType() + isCloudNode()/isGroup() 虚方法 |
4.6 SceneNode |
| 15 | VTK 可见性联动 | 定义 SceneTreeManager::refreshVTKView() 遍历所有节点,对 visible=true 的 CloudNode 调用 VTK SetVisibility(true),false 的调用 SetVisibility(false) |
6.1 § 6 |
| 16 | 删除子节点处理 | 级联删除:删除父节点时递归删除所有子节点(与 CloudCompare 一致) | 6.1 removeNode() |
| 17 | 多节点 BBox 管理 | 各自独立显示 :每个可见 CloudNode 按 showBBox 独立控制是否显示其 BBox 线框 |
4.2 DisplayProperty::showBBox |
| 18 | 点云数据所有权 | setCloud() 接收 shared_ptr(浅拷贝引用计数),不复制底层点云数据;子节点结果通过 addResultNode 传递新分配的 Ptr |
4.9 CloudNode::setCloud() |
| 19 | 拖拽排序 | 暂不实现,Phase 4 考虑 | 标记为 ⊘ |
13.4 设计决策修正建议 ✅ 已全部采纳
| 原设计 | 修正后 | 状态 |
|---|---|---|
OpType 枚举列具体操作 |
改为 OpCategory + OpInfo(字符串名),两层设计 |
✅ 已改 |
OperationRouter 全部 static |
改为 QObject 基类,信号驱动,预留插件注册 |
✅ 已改 |
| INI 参数硬编码解析 | FilterConfig 类管理,get/set 通用接口 |
✅ 已改 |
BoundingBox 只有 AABB |
预留 globalShift/globalScale,OBB 在 §13.2 #7 预留 |
✅ 已改 |
| 无节点唯一标识 | SceneNode 增加 quint64 id() + UniqueIdGenerator |
✅ 已改 |