从零构建一个3D点云预览器:Python + PySide6 + pyqtgraph 实战

从零构建一个3D点云预览器:Python + PySide6 + pyqtgraph 实战

本文基于一个实际项目,记录了我用 Python 搭建带图形界面的 3D 点云预览工具的全过程。从点云是什么、为什么选这些库,到核心原理、架构设计和踩过的坑,尽量用大白话讲清楚。适合对 3D 可视化感兴趣、想用 Python 做点东西的开发者。

一、点云是什么?为什么要预览它?

点云(Point Cloud)简单说就是三维空间里一大堆点的集合 。每个点通常有三个坐标 (x, y, z),有的还带颜色 (r, g, b) 或反射强度。你可以把它想象成夏夜的星空:每一颗星星就是一个点,只不过点云里的"星星"可能有几百万甚至上亿颗,而且它们不是随机分布,而是勾勒出物体或场景的形状。

激光雷达(LiDAR)、结构光扫描仪、摄影测量(多张照片重建)都会产生点云。比如自动驾驶车顶的激光雷达扫一圈,就能得到周围环境的点云;用手机扫描房间,也能得到室内点云。

拿到点云后,第一件事往往是看一看它长什么样------有没有噪声、范围多大、形状对不对。这就需要预览工具。商业软件不少,但自己用 Python 写一个,既能定制功能,又能顺便搞懂 3D 渲染的原理。

二、技术选型:为什么是它们?

需求 选型 理由
图形界面 PySide6(或 PyQt5) Qt 是桌面 GUI 的事实标准,PySide6 是官方绑定,LGPL 协议友好
3D 渲染 pyqtgraph.opengl 纯 Python + OpenGL,能直接嵌进 Qt 窗口,轻量、够用
点云加载 Open3D 支持 PLY、PCD、XYZ、LAS 等格式,读取几行代码搞定
数值计算 NumPy 点云本质就是 (N, 3) 数组,NumPy 操作快且方便

为什么不选更"专业"的 VTK 或 PCL?VTK 功能强但学习曲线陡、包体大;PCL 是 C++ 库,Python 绑定安装麻烦。对于"预览"这个需求,pyqtgraph + Open3D 的组合已经绰绰有余。

三、核心原理:点云是怎么变成屏幕上的画面的?

3.1 加载:把各种格式翻译成统一数组

点云文件格式五花八门:

  • PLY / PCD:二进制或文本,Open3D 直接读。
  • LAS / LAZ :激光雷达标准格式,需要 laspy。
  • XYZ / TXT :纯文本,每行 x y z [r g b],用 numpy.loadtxt 兜底。

统一目标:得到一个 points 数组(形状 N×3,float32)和可选的 colors 数组(形状 N×3,0~1 之间)。Open3D 的 read_point_cloud 返回对象,再用 np.asarray 转成数组即可。

3.2 渲染:OpenGL 画点

pyqtgraph 的 GLViewWidget 封装了 OpenGL,我们只需要创建一个 GLScatterPlotItem,把点坐标和颜色传进去,它就会在 3D 空间里画出每个点。

关键点:

  • OpenGL 是状态机,但 pyqtgraph 帮我们屏蔽了底层细节。
  • 点的大小用像素表示(pxMode=True),这样缩放时点不会变得巨大。
  • 颜色是 RGBA,范围 0~1。如果原始颜色是 0~255,要除以 255。

3.3 高度伪彩色:让 Z 值"说话"

如果点云没有颜色,怎么让它好看又信息丰富?按高度着色是最常用的手法。原理很简单:

  1. 找出 Z 的最小值和最大值。
  2. 把每个点的 Z 归一化到 [0, 1]。
  3. 用 numpy.interp 在颜色带上插值(深蓝→青→绿→黄→红)。

这样高处红、低处蓝,地形起伏一目了然。代码核心就几行:

python

复制代码
t = (z - zmin) / (zmax - zmin)
r = np.interp(t, stops, colors_r)
# 同理 g、b

3.4 下采样:点太多会卡怎么办?

几百万个点同时画,显卡扛不住。解决办法是下采样------每隔几个点取一个。比如总共有 500 万点,但只想显示 50 万,就每隔 10 个取一个:

python

复制代码
step = int(np.ceil(n / max_points))
pts = pts[::step]

这样既保持空间分布,又大幅减少渲染压力。比随机采样更快,效果也够用。

四、软件构建思路:分层架构

整个程序可以分成四层:

text

复制代码
┌─────────────────────────────┐
│  UI 层 (PySide6)            │  按钮、列表、滑块、菜单
├─────────────────────────────┤
│  视图层 (pyqtgraph.opengl)  │  多个 3D 视图,同步相机
├─────────────────────────────┤
│  数据层 (PointCloudLayer)   │  每个图层的点、颜色、裁剪、缓存
├─────────────────────────────┤
│  加载层 (Open3D + numpy)    │  读取各种格式,返回数组
└─────────────────────────────┘

4.1 数据层:PointCloudLayer

每个加载的文件变成一个图层对象,保存:

  • 原始点坐标、原始颜色
  • 显示点坐标(居中后)
  • 可见性、颜色模式、裁剪范围
  • 缓存(避免重复计算)

居中处理:如果点云坐标是 UTM 坐标(几十万米),直接画会有浮点精度问题。所以第一次加载时以质心为原点,所有点减去这个原点,相机也围绕原点转。

4.2 视图层:多视图同步

多视图同步的难点在于:一个视图的相机变了,其他视图要跟着变,但不能无限递归。做法是:

  • 自定义 SyncGLViewWidget,重写 setCameraPosition,发一个信号。
  • 主窗口收到信号后,计算基准方位角(当前视图方位角减去它的偏移量),再给其他视图设置 基准方位角 + 各自偏移。
  • 同步时临时关闭信号发射,避免循环触发。

四个视图的方位角偏移分别是 0°、90°、180°、270°,所以能从四个方向同时观察。

4.3 UI 层:侧边栏与分割器

用 QSplitter 把窗口分成左右两部分:左边控制面板,右边 3D 视图区。分割器可以拖动,但左侧要设置最小宽度,保证所有控件都能显示完整。否则拖窄了,输入框、按钮就被裁掉了。

左侧内容放在 QScrollArea 里,如果控件总高度超过窗口,会自动出现滚动条。

五、功能实现细节

5.1 多文件批量加载与图层管理

  • 菜单"打开文件"支持多选,每个文件生成一个图层。
  • 左侧 QListWidget 显示图层名和点数,带复选框控制显隐。
  • 选中某个图层后,颜色模式、裁剪范围等操作只作用于它。

5.2 包围盒裁剪

每个图层有一个轴对齐包围盒(AABB),用六个数字框设置 X/Y/Z 的最小值和最大值。裁剪时用布尔掩码过滤点:

python

复制代码
mask = np.all((pts >= clip_min) & (pts <= clip_max), axis=1)
pts = pts[mask]

为了避免拖动数字框时频繁重绘,加了一个 120ms 的防抖定时器。

5.3 截图导出

用 grabFramebuffer() 抓取每个视图的画面,然后用 QPainter 拼接成一张大图。单视图直接保存,2×2 视图拼成 2×2 的画布。

5.4 拖拽打开

重写 dragEnterEvent 和 dropEvent,把文件直接拖进窗口就能加载。

六、踩过的坑

  1. QVector3D 类型错误
    pyqtgraph 的 opts['center'] 必须是 QVector3D,如果传 numpy 数组,内部调用 .x() 就会崩。解决:在 reset_view 里显式转成 QVector3D。
  2. 导入笔误
    QPainte 少了个 r,导致 PySide6 导入失败,又去回退到未安装的 PyQt5。仔细检查拼写即可。
  3. 侧边栏显示不全
    QSplitter 默认可以拖到很窄,把内容裁掉。给左侧容器设置 setMinimumWidth(340),内部 left 也设最小宽度,就稳了。
  4. 性能问题
    大点云不加下采样会卡死。用步进切片下采样,并给图层加缓存,避免切换颜色模式时重复计算。

具体代码如下:

python 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
3D 点云预览器(进阶版)
功能:
  - 多文件批量加载 / 图层列表 / 可见性开关
  - 包围盒裁剪(每个图层独立)
  - 1×1 / 1×2 / 2×2 多视图同步旋转
  - 截图导出(多视图自动拼接)
  - 高度伪彩色 / 原始颜色 / 单色
  - 点大小、背景色、坐标轴开关、下采样
"""

import os
import sys
import numpy as np

# ----------------------------------------------------------------
# Qt 绑定兼容层
# ----------------------------------------------------------------
# ----------------------------------------------------------------
# Qt 绑定兼容层(优先 PySide6,自动回退 PyQt5)
# ----------------------------------------------------------------
try:
    from PySide6.QtCore import Qt, Signal, QTimer
    from PySide6.QtGui import QAction, QColor, QImage, QPainter, QVector3D
    from PySide6.QtWidgets import (
        QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
        QGridLayout, QPushButton, QLabel, QFileDialog, QSlider, QComboBox,
        QGroupBox, QStatusBar, QMessageBox, QColorDialog, QCheckBox,
        QSpinBox, QDoubleSpinBox, QListWidget, QListWidgetItem,
        QScrollArea, QSplitter,
    )
    QT_BINDING = "PySide6"
except ImportError:
    from PyQt5.QtCore import Qt, pyqtSignal as Signal, QTimer
    from PyQt5.QtGui import QColor, QImage, QPainter, QVector3D
    from PyQt5.QtWidgets import (
        QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
        QGridLayout, QPushButton, QLabel, QFileDialog, QSlider, QComboBox,
        QGroupBox, QStatusBar, QMessageBox, QColorDialog, QCheckBox,
        QSpinBox, QDoubleSpinBox, QListWidget, QListWidgetItem,
        QScrollArea, QSplitter, QAction,
    )
    QT_BINDING = "PyQt5"

import pyqtgraph.opengl as gl
import open3d as o3d


# ================================================================
# 1. 数据加载
# ================================================================

def _read_text_cloud(path):
    data = np.loadtxt(path, comments=('#', '//', ';'), ndmin=2)
    if data.shape[1] < 3:
        raise ValueError(f'文本点云至少需要 3 列 (x y z),实际 {data.shape[1]} 列')
    points = data[:, :3].astype(np.float32)

    colors = None
    if data.shape[1] >= 6:
        colors = data[:, 3:6].astype(np.float32)
        if colors.max() > 1.0:
            colors /= 255.0
        colors = np.clip(colors, 0.0, 1.0)
    return points, colors


def _read_las(path):
    try:
        import laspy
    except ImportError as e:
        raise ImportError('读取 .las/.laz 需要 laspy:pip install laspy') from e
    las = laspy.read(path)
    points = np.vstack((las.x, las.y, las.z)).T.astype(np.float32)

    colors = None
    dims = set(las.point_format.dimension_names)
    if {'red', 'green', 'blue'} <= dims:
        rgb = np.vstack((las.red, las.green, las.blue)).T.astype(np.float32)
        if rgb.max() > 1.0:
            rgb /= 65535.0
        colors = np.clip(rgb, 0.0, 1.0)
    return points, colors


def load_point_cloud_data(path):
    if not os.path.isfile(path):
        raise FileNotFoundError(f'文件不存在:{path}')
    ext = os.path.splitext(path)[1].lower()

    if ext in ('.las', '.laz'):
        return _read_las(path)

    if ext in ('.ply', '.pcd', '.xyz', '.xyzn', '.xyzrgb', '.pts'):
        try:
            pcd = o3d.io.read_point_cloud(path)
            if len(pcd.points) > 0:
                pts = np.asarray(pcd.points, dtype=np.float32)
                cols = (np.asarray(pcd.colors, dtype=np.float32)
                        if pcd.has_colors() else None)
                return pts, cols
        except Exception:
            pass

    return _read_text_cloud(path)


# ================================================================
# 2. 高度伪彩色
# ================================================================

_HEIGHT_STOPS = np.array([0.0, 0.25, 0.5, 0.75, 1.0], dtype=np.float32)
_HEIGHT_COLORS = np.array([
    [0.05, 0.10, 0.65],
    [0.00, 0.75, 1.00],
    [0.05, 0.80, 0.20],
    [1.00, 0.85, 0.00],
    [0.95, 0.10, 0.05],
], dtype=np.float32)


def height_colormap(points):
    if len(points) == 0:
        return np.zeros((0, 3), dtype=np.float32)
    z = points[:, 2]
    zmin, zmax = float(z.min()), float(z.max())
    t = (z - zmin) / (zmax - zmin) if zmax > zmin else np.zeros(len(z), dtype=np.float32)
    return np.stack(
        [np.interp(t, _HEIGHT_STOPS, _HEIGHT_COLORS[:, c]) for c in range(3)],
        axis=1,
    ).astype(np.float32)


# ================================================================
# 3. 图层
# ================================================================

class PointCloudLayer:
    def __init__(self, name, points, colors, origin):
        self.name = name
        self.original_points = points
        self.points = (points - origin).astype(np.float32)
        self.original_colors = colors

        self.visible = True
        self.color_mode = 0          # 0=auto, 1=height, 2=single
        self.single_color = np.array([0.20, 0.70, 1.00], dtype=np.float32)

        # 裁剪(在显示坐标系下)
        bmin = self.points.min(axis=0).astype(np.float32)
        bmax = self.points.max(axis=0).astype(np.float32)
        self.clip_enabled = False
        self.clip_min = bmin.copy()
        self.clip_max = bmax.copy()

        # 缓存
        self._cache_key = None
        self._cache_data = None

    def invalidate_cache(self):
        self._cache_key = None
        self._cache_data = None

    def get_display_data(self, max_points):
        pts = self.points
        cols = self.original_colors

        # 裁剪
        if self.clip_enabled:
            mask = np.all((pts >= self.clip_min) & (pts <= self.clip_max), axis=1)
            pts = pts[mask]
            if cols is not None:
                cols = cols[mask]

        # 下采样
        n = len(pts)
        if n > max_points > 0:
            step = int(np.ceil(n / max_points))
            pts = pts[::step]
            if cols is not None:
                cols = cols[::step]

        # 颜色
        if self.color_mode == 0:
            if cols is None:
                cols = height_colormap(pts)
        elif self.color_mode == 1:
            cols = height_colormap(pts)
        else:
            cols = np.tile(self.single_color, (len(pts), 1))

        if cols.shape[1] == 3:
            cols = np.hstack([cols, np.ones((len(cols), 1), dtype=np.float32)])

        return (pts.astype(np.float32),
                cols.astype(np.float32))


# ================================================================
# 4. 支持相机同步的 GL 视图
# ================================================================

class SyncGLViewWidget(gl.GLViewWidget):
    cameraChanged = Signal(object)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._emit_enabled = True

    def setCameraPosition(self, *args, **kwargs):
        super().setCameraPosition(*args, **kwargs)
        if self._emit_enabled:
            self.cameraChanged.emit(self)


# ================================================================
# 5. 主窗口
# ================================================================

class PointCloudViewer(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle(f'3D 点云预览器(进阶版)  [{QT_BINDING}]')
        self.resize(1560, 940)

        self.layers = []
        self.origin = None
        self.views = []
        self.view_offsets = []
        self._all_scatter_items = []
        self._syncing = False
        self._bg_color = (30, 33, 40, 255)
        self.current_layer_idx = -1

        # 裁剪防抖
        self._clip_timer = QTimer()
        self._clip_timer.setSingleShot(True)
        self._clip_timer.setInterval(120)
        self._clip_timer.timeout.connect(self._apply_clip)

        self._build_ui()
        self._apply_dark_theme()
        self._setup_view_grid(1)

        # __init__ 里,_setup_view_grid(1) 之后
        from PySide6.QtCore import QSettings  # 顶部也可以
        self._settings = QSettings('PointCloudViewer', 'PCViewer')
        saved = self._settings.value('splitter_sizes')
        if saved:
            try:
                sizes = [int(x) for x in saved]
                self.splitter.setSizes(sizes)
            except Exception:
                pass
        self.setAcceptDrops(True)

    def closeEvent(self, event):
        try:
            self._settings.setValue('splitter_sizes', self.splitter.sizes())
        except Exception:
            pass
        super().closeEvent(event)

    # ---------------- UI ----------------
    def _build_ui(self):
        # -------- 菜单栏 --------
        menubar = self.menuBar()

        file_menu = menubar.addMenu('文件(&F)')
        act_open = QAction('打开文件...', self)
        act_open.setShortcut('Ctrl+O')
        act_open.triggered.connect(self.open_files_dialog)
        file_menu.addAction(act_open)

        act_add = QAction('追加文件...', self)
        act_add.setShortcut('Ctrl+Shift+O')
        act_add.triggered.connect(self.add_files_dialog)
        file_menu.addAction(act_add)

        file_menu.addSeparator()
        act_shot = QAction('导出截图...', self)
        act_shot.setShortcut('Ctrl+S')
        act_shot.triggered.connect(self.export_screenshot)
        file_menu.addAction(act_shot)

        file_menu.addSeparator()
        act_clear = QAction('清空全部(&C)', self)
        act_clear.triggered.connect(self.clear_all)
        file_menu.addAction(act_clear)

        act_quit = QAction('退出(&Q)', self)
        act_quit.setShortcut('Ctrl+Q')
        act_quit.triggered.connect(self.close)
        file_menu.addAction(act_quit)

        view_menu = menubar.addMenu('视图(&V)')
        self.act_v1 = QAction('单视图  (1×1)', self)
        self.act_v2 = QAction('双视图  (1×2)', self)
        self.act_v4 = QAction('四视图  (2×2)', self)
        self.act_v1.triggered.connect(lambda: self._setup_view_grid(1))
        self.act_v2.triggered.connect(lambda: self._setup_view_grid(2))
        self.act_v4.triggered.connect(lambda: self._setup_view_grid(4))
        view_menu.addAction(self.act_v1)
        view_menu.addAction(self.act_v2)
        view_menu.addAction(self.act_v4)

        view_menu.addSeparator()
        act_reset = QAction('重置视角(&R)', self)
        act_reset.setShortcut('R')
        act_reset.triggered.connect(self.reset_view)
        view_menu.addAction(act_reset)

        help_menu = menubar.addMenu('帮助(&H)')
        act_about = QAction('关于(&A)', self)
        act_about.triggered.connect(self.show_about)
        help_menu.addAction(act_about)

        # -------- 中央 --------
        central = QWidget()
        self.setCentralWidget(central)
        root_layout = QHBoxLayout(central)
        root_layout.setContentsMargins(0, 0, 0, 0)
        root_layout.setSpacing(0)

        # ==== 水平分割器:左控制栏 | 右视图区 ====
        self.splitter = QSplitter(Qt.Horizontal)
        self.splitter.setChildrenCollapsible(False)  # 不允许拖到 0
        self.splitter.setHandleWidth(6)
        self.splitter.setOpaqueResize(True)
        root_layout.addWidget(self.splitter)

        # ----- 左侧控制面板(放在可滚动容器里)-----
        left_scroll = QScrollArea()
        left_scroll.setWidgetResizable(True)
        left_scroll.setFrameShape(QScrollArea.NoFrame)
        left_scroll.setMinimumWidth(340)
        left_scroll.setMaximumWidth(700)

        left = QWidget()
        left.setMinimumWidth(320)  # ← 新增
        lv = QVBoxLayout(left)
        lv.setContentsMargins(10, 10, 10, 10)
        lv.setSpacing(10)

        # 打开按钮
        self.btn_open = QPushButton('📂  打开点云文件')
        self.btn_open.setMinimumHeight(42)
        self.btn_open.setStyleSheet(
            'QPushButton {'
            '  background-color: #2d7dd2; color: white;'
            '  border-radius: 6px; font-size: 14px; font-weight: bold;'
            '}'
            'QPushButton:hover { background-color: #3d8de2; }'
            'QPushButton:pressed { background-color: #1d6dc2; }'
        )
        self.btn_open.clicked.connect(self.open_files_dialog)
        lv.addWidget(self.btn_open)

        # 图层列表
        grp_layers = QGroupBox('图层')
        gl_ = QVBoxLayout(grp_layers)

        self.layer_list = QListWidget()
        self.layer_list.setMinimumHeight(160)
        self.layer_list.itemChanged.connect(self._on_layer_item_changed)
        self.layer_list.currentRowChanged.connect(self._on_layer_row_changed)
        gl_.addWidget(self.layer_list)

        row = QHBoxLayout()
        btn_rm = QPushButton('移除选中')
        btn_rm.clicked.connect(self.remove_selected_layer)
        btn_clear = QPushButton('清空')
        btn_clear.clicked.connect(self.clear_all)
        row.addWidget(btn_rm)
        row.addWidget(btn_clear)
        gl_.addLayout(row)

        lv.addWidget(grp_layers)

        # 渲染
        grp_render = QGroupBox('渲染')
        rv = QVBoxLayout(grp_render)

        rv.addWidget(QLabel('点大小:'))
        self.slider_size = QSlider(Qt.Horizontal)
        self.slider_size.setRange(1, 20)
        self.slider_size.setValue(3)
        self.slider_size.valueChanged.connect(self._on_point_size_changed)
        rv.addWidget(self.slider_size)

        rv.addSpacing(4)
        rv.addWidget(QLabel('颜色模式(应用于选中图层):'))
        self.combo_color = QComboBox()
        self.combo_color.addItems(['自动(原始优先)', '按高度着色', '单一颜色'])
        self.combo_color.currentIndexChanged.connect(self._on_color_mode_changed)
        rv.addWidget(self.combo_color)

        rv.addSpacing(4)
        self.btn_bg = QPushButton('🎨  背景颜色...')
        self.btn_bg.clicked.connect(self.choose_background)
        rv.addWidget(self.btn_bg)

        self.chk_axes = QCheckBox('显示坐标轴')
        self.chk_axes.setChecked(True)
        self.chk_axes.stateChanged.connect(self._rebuild_all_views)
        rv.addWidget(self.chk_axes)

        lv.addWidget(grp_render)

        # 裁剪
        grp_clip = QGroupBox('包围盒裁剪(选中图层)')
        cv = QVBoxLayout(grp_clip)

        self.chk_clip = QCheckBox('启用裁剪')
        self.chk_clip.stateChanged.connect(self._on_clip_changed)
        cv.addWidget(self.chk_clip)

        self.clip_spins = {}
        for axis_name, axis_idx in [('X', 0), ('Y', 1), ('Z', 2)]:
            hrow = QHBoxLayout()
            hrow.addWidget(QLabel(f'{axis_name}:'))
            mn = QDoubleSpinBox()
            mn.setRange(-1e9, 1e9)
            mn.setDecimals(3)
            mn.setSingleStep(0.1)
            mn.valueChanged.connect(self._on_clip_changed)

            mx = QDoubleSpinBox()
            mx.setRange(-1e9, 1e9)
            mx.setDecimals(3)
            mx.setSingleStep(0.1)
            mx.valueChanged.connect(self._on_clip_changed)

            hrow.addWidget(mn, 1)
            hrow.addWidget(QLabel('~'))
            hrow.addWidget(mx, 1)
            cv.addLayout(hrow)
            self.clip_spins[axis_idx] = (mn, mx)

        btn_fit = QPushButton('适配当前图层范围')
        btn_fit.clicked.connect(self._clip_fit_current)
        cv.addWidget(btn_fit)

        lv.addWidget(grp_clip)

        # 性能
        grp_perf = QGroupBox('性能')
        pv = QVBoxLayout(grp_perf)

        self.lbl_total = QLabel('总点数: -')
        pv.addWidget(self.lbl_total)

        pv.addWidget(QLabel('每图层最大显示点数:'))
        self.spin_max = QSpinBox()
        self.spin_max.setRange(10_000, 20_000_000)
        self.spin_max.setValue(500_000)
        self.spin_max.setSingleStep(100_000)
        self.spin_max.valueChanged.connect(self._on_max_points_changed)
        pv.addWidget(self.spin_max)

        self.lbl_shown = QLabel('实际显示: -')
        pv.addWidget(self.lbl_shown)

        lv.addWidget(grp_perf)

        lv.addStretch(1)

        self.btn_reset = QPushButton('🔄  重置视角')
        self.btn_reset.setMinimumHeight(34)
        self.btn_reset.clicked.connect(self.reset_view)
        lv.addWidget(self.btn_reset)

        left_scroll.setWidget(left)
        self.splitter.addWidget(left_scroll)

        # ----- 右侧视图网格 -----
        right = QWidget()
        right.setMinimumWidth(400)
        self.view_layout = QGridLayout(right)
        self.view_layout.setContentsMargins(6, 6, 6, 6)
        self.view_layout.setSpacing(6)
        self.splitter.addWidget(right)

        # 初始宽度:左 320px,其余全部给右侧
        self.splitter.setStretchFactor(0, 0)
        self.splitter.setStretchFactor(1, 1)
        self.splitter.setSizes([320, 1200])

        # 状态栏
        self.status = QStatusBar()
        self.setStatusBar(self.status)
        self.status.showMessage('就绪 --- 打开文件或直接拖拽到窗口')

    def _apply_dark_theme(self):
        self.setStyleSheet("""
            QMainWindow, QWidget { background-color: #1f2229; color: #dcdcdc; }
            QScrollArea { background-color: #1f2229; border: none; }
            QGroupBox {
                border: 1px solid #3a3f4b; border-radius: 6px;
                margin-top: 10px; padding-top: 10px; font-weight: bold;
            }
            QGroupBox::title {
                subcontrol-origin: margin; left: 10px; padding: 0 4px;
                color: #8ab4f8;
            }
            QPushButton {
                background-color: #2b303b; color: #dcdcdc;
                border: 1px solid #3a3f4b; border-radius: 5px;
                padding: 6px 10px;
            }
            QPushButton:hover { background-color: #353b48; }
            QPushButton:pressed { background-color: #20242c; }
            QSlider::groove:horizontal {
                height: 5px; background: #3a3f4b; border-radius: 3px;
            }
            QSlider::handle:horizontal {
                background: #2d7dd2; width: 14px; margin: -5px 0; border-radius: 7px;
            }
            QComboBox, QSpinBox, QDoubleSpinBox {
                background-color: #2b303b; border: 1px solid #3a3f4b;
                border-radius: 4px; padding: 3px; color: #dcdcdc;
            }
            QListWidget {
                background-color: #262a32; border: 1px solid #3a3f4b;
                border-radius: 4px;
            }
            QListWidget::item { padding: 4px; }
            QListWidget::item:selected { background-color: #2d7dd2; }
            QCheckBox { color: #dcdcdc; }
            QMenuBar, QMenu { background-color: #262a32; color: #dcdcdc; }
            QMenuBar::item:selected, QMenu::item:selected { background-color: #2d7dd2; }
            QStatusBar { background-color: #262a32; color: #a0a0a0; }
            QLabel { color: #dcdcdc; }
                        QSplitter::handle {
                background-color: #2b303b;
            }
            QSplitter::handle:horizontal {
                width: 6px;
            }
            QSplitter::handle:hover {
                background-color: #2d7dd2;
            }
        """)

    # ---------------- 视图网格 ----------------
    def _setup_view_grid(self, n):
        # 清空旧视图
        for i in reversed(range(self.view_layout.count())):
            w = self.view_layout.itemAt(i).widget()
            if w is not None:
                w.setParent(None)
                w.deleteLater()
        self.views.clear()
        self.view_offsets.clear()
        self._all_scatter_items.clear()

        layouts = {
            1: (1, 1, [0]),
            2: (1, 2, [0, 90]),
            4: (2, 2, [0, 90, 180, 270]),
        }
        rows, cols, offsets = layouts.get(n, layouts[1])

        for i in range(n):
            v = SyncGLViewWidget()
            v.setBackgroundColor(self._bg_color)
            v.opts['fov'] = 55
            v.setCameraPosition(distance=10, elevation=22, azimuth=offsets[i])

            self.views.append(v)
            self.view_offsets.append(offsets[i])
            v.cameraChanged.connect(self._on_camera_changed)

            self.view_layout.addWidget(v, i // cols, i % cols)

        self._rebuild_all_views()
        self.reset_view()

    def _on_camera_changed(self, source):
        if self._syncing or len(self.views) <= 1:
            return
        if source not in self.views:
            return
        idx = self.views.index(source)

        self._syncing = True
        try:
            base_az = float(source.opts['azimuth']) - self.view_offsets[idx]
            elev = float(source.opts['elevation'])
            dist = float(source.opts['distance'])
            center = source.opts['center']

            for i, v in enumerate(self.views):
                if i == idx:
                    continue
                v._emit_enabled = False
                try:
                    v.setCameraPosition(
                        pos=center,
                        distance=dist,
                        elevation=elev,
                        azimuth=base_az + self.view_offsets[i],
                    )
                finally:
                    v._emit_enabled = True
        finally:
            self._syncing = False

    # ---------------- 场景重建 ----------------
    def _get_layer_display(self, layer):
        key = (
            layer.color_mode,
            tuple(layer.single_color.tolist()),
            layer.clip_enabled,
            tuple(layer.clip_min.tolist()),
            tuple(layer.clip_max.tolist()),
            self.spin_max.value(),
        )
        if layer._cache_key != key or layer._cache_data is None:
            layer._cache_data = layer.get_display_data(self.spin_max.value())
            layer._cache_key = key
        return layer._cache_data

    def _rebuild_all_views(self):
        self._all_scatter_items.clear()

        for v in self.views:
            v.clear()

        if not self.layers:
            self.lbl_shown.setText('实际显示: -')
            return

        # 场景包围盒(用于坐标轴)
        all_min, all_max = None, None
        for layer in self.layers:
            if not layer.visible or len(layer.points) == 0:
                continue
            mn = layer.points.min(axis=0)
            mx = layer.points.max(axis=0)
            if all_min is None:
                all_min, all_max = mn, mx
            else:
                all_min = np.minimum(all_min, mn)
                all_max = np.maximum(all_max, mx)

        total_shown = 0
        size = self.slider_size.value()

        for v in self.views:
            if all_min is not None and self.chk_axes.isChecked():
                self._add_axes_to_view(v, all_min, all_max)

            for layer in self.layers:
                if not layer.visible:
                    continue
                pts, cols = self._get_layer_display(layer)
                if len(pts) == 0:
                    continue
                item = gl.GLScatterPlotItem(
                    pos=pts, color=cols, size=size, pxMode=True,
                )
                v.addItem(item)
                self._all_scatter_items.append(item)
                total_shown += len(pts)

        if self.views:
            total_shown = total_shown // max(1, len(self.views))
        self.lbl_shown.setText(f'实际显示(单视图): {total_shown:,}')

    def _add_axes_to_view(self, view, mn, mx):
        extent = float(np.max(mx - mn))
        if extent <= 0:
            extent = 1.0
        axis_len = extent * 0.12
        origin = (mn - axis_len * 0.1).astype(np.float32)

        specs = [
            (origin + np.array([axis_len, 0, 0], dtype=np.float32), (1.00, 0.35, 0.35, 1.0)),
            (origin + np.array([0, axis_len, 0], dtype=np.float32), (0.35, 1.00, 0.35, 1.0)),
            (origin + np.array([0, 0, axis_len], dtype=np.float32), (0.40, 0.55, 1.00, 1.0)),
        ]
        for end, color in specs:
            line = gl.GLLinePlotItem(
                pos=np.array([origin, end], dtype=np.float32),
                color=color, width=2.0, antialias=True,
            )
            view.addItem(line)

    # ---------------- 文件操作 ----------------
    def open_files_dialog(self):
        paths, _ = QFileDialog.getOpenFileNames(
            self, '选择点云文件(可多选)', '',
            '点云文件 (*.ply *.pcd *.xyz *.xyzn *.xyzrgb *.pts *.las *.laz *.txt);;'
            'PLY (*.ply);;PCD (*.pcd);;LAS/LAZ (*.las *.laz);;'
            '文本 (*.xyz *.pts *.txt);;所有文件 (*)'
        )
        if not paths:
            return
        for p in paths:
            self._add_file(p)

    def add_files_dialog(self):
        self.open_files_dialog()

    def _add_file(self, path):
        self.status.showMessage(f'正在加载 {os.path.basename(path)} ...')
        QApplication.processEvents()

        try:
            pts, cols = load_point_cloud_data(path)
        except Exception as e:
            QMessageBox.critical(self, '加载失败', f'{path}\n\n{type(e).__name__}: {e}')
            self.status.showMessage('加载失败')
            return

        if len(pts) == 0:
            QMessageBox.warning(self, '空文件', f'{path}\n\n未读取到任何点。')
            return

        if self.origin is None:
            self.origin = pts.mean(axis=0).astype(np.float32)

        layer = PointCloudLayer(
            name=os.path.basename(path),
            points=pts,
            colors=cols,
            origin=self.origin,
        )
        self.layers.append(layer)

        self._refresh_layer_list()
        self._rebuild_all_views()
        self._update_total_label()

        if len(self.layers) == 1:
            self.reset_view()
            self.layer_list.setCurrentRow(0)

        self.status.showMessage(
            f'已加载 {layer.name}  ({len(pts):,} 点)  '
            f'--- 共 {len(self.layers)} 个图层'
        )

    def _refresh_layer_list(self):
        self.layer_list.blockSignals(True)
        self.layer_list.clear()
        for layer in self.layers:
            item = QListWidgetItem()
            tag = '' if layer.original_colors is None else ' 🎨'
            item.setText(f'{layer.name}{tag}  ({len(layer.points):,})')
            item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
            item.setCheckState(Qt.Checked if layer.visible else Qt.Unchecked)
            self.layer_list.addItem(item)
        self.layer_list.blockSignals(False)

    def _update_total_label(self):
        total = sum(len(l.points) for l in self.layers)
        self.lbl_total.setText(f'总点数: {total:,}  ({len(self.layers)} 图层)')

    def remove_selected_layer(self):
        idx = self.layer_list.currentRow()
        if idx < 0 or idx >= len(self.layers):
            return
        del self.layers[idx]
        if not self.layers:
            self.origin = None
        self._refresh_layer_list()
        self._rebuild_all_views()
        self._update_total_label()
        if self.layers:
            self.layer_list.setCurrentRow(min(idx, len(self.layers) - 1))
        else:
            self.current_layer_idx = -1
            self._sync_clip_ui_from_layer()

    def clear_all(self):
        if not self.layers:
            return
        ret = QMessageBox.question(
            self, '确认', f'清空所有 {len(self.layers)} 个图层?',
            QMessageBox.Yes | QMessageBox.No, QMessageBox.No
        )
        if ret != QMessageBox.Yes:
            return
        self.layers.clear()
        self.origin = None
        self.current_layer_idx = -1
        self._refresh_layer_list()
        self._rebuild_all_views()
        self._update_total_label()
        self._sync_clip_ui_from_layer()
        self.status.showMessage('已清空')

    # ---------------- 图层事件 ----------------
    def _on_layer_item_changed(self, item):
        row = self.layer_list.row(item)
        if row < 0 or row >= len(self.layers):
            return
        layer = self.layers[row]
        new_vis = (item.checkState() == Qt.Checked)
        if layer.visible != new_vis:
            layer.visible = new_vis
            self._rebuild_all_views()

    def _on_layer_row_changed(self, row):
        self.current_layer_idx = row
        self._sync_clip_ui_from_layer()

        if 0 <= row < len(self.layers):
            self.combo_color.blockSignals(True)
            self.combo_color.setCurrentIndex(self.layers[row].color_mode)
            self.combo_color.blockSignals(False)

    # ---------------- 渲染回调 ----------------
    def _on_point_size_changed(self, v):
        for item in self._all_scatter_items:
            item.setData(size=v)

    def _on_color_mode_changed(self, mode):
        idx = self.current_layer_idx
        if idx < 0 or idx >= len(self.layers):
            return
        self.layers[idx].color_mode = mode
        self.layers[idx].invalidate_cache()
        self._rebuild_all_views()

    def _on_max_points_changed(self, _):
        for layer in self.layers:
            layer.invalidate_cache()
        self._rebuild_all_views()

    def choose_background(self):
        col = QColorDialog.getColor(QColor(*self._bg_color[:3]), self, '选择背景颜色')
        if col.isValid():
            self._bg_color = (col.red(), col.green(), col.blue(), 255)
            for v in self.views:
                v.setBackgroundColor(self._bg_color)

    # ---------------- 裁剪 ----------------
    def _sync_clip_ui_from_layer(self):
        idx = self.current_layer_idx
        has = 0 <= idx < len(self.layers)

        for spins in self.clip_spins.values():
            spins[0].blockSignals(True)
            spins[1].blockSignals(True)

        self.chk_clip.blockSignals(True)

        if has:
            layer = self.layers[idx]
            for axis_idx, (mn, mx) in self.clip_spins.items():
                mn.setValue(float(layer.clip_min[axis_idx]))
                mx.setValue(float(layer.clip_max[axis_idx]))
            self.chk_clip.setChecked(layer.clip_enabled)
            self.chk_clip.setEnabled(True)
        else:
            for mn, mx in self.clip_spins.values():
                mn.setValue(0.0)
                mx.setValue(0.0)
            self.chk_clip.setChecked(False)
            self.chk_clip.setEnabled(False)

        for spins in self.clip_spins.values():
            spins[0].blockSignals(False)
            spins[1].blockSignals(False)
        self.chk_clip.blockSignals(False)

    def _on_clip_changed(self, *_):
        self._clip_timer.start()

    def _apply_clip(self):
        idx = self.current_layer_idx
        if idx < 0 or idx >= len(self.layers):
            return

        layer = self.layers[idx]
        new_enabled = self.chk_clip.isChecked()

        new_min = np.array(
            [self.clip_spins[i][0].value() for i in range(3)],
            dtype=np.float32,
        )
        new_max = np.array(
            [self.clip_spins[i][1].value() for i in range(3)],
            dtype=np.float32,
        )

        changed = (
            layer.clip_enabled != new_enabled
            or not np.allclose(layer.clip_min, new_min)
            or not np.allclose(layer.clip_max, new_max)
        )
        if not changed:
            return

        layer.clip_enabled = new_enabled
        layer.clip_min = new_min
        layer.clip_max = new_max
        layer.invalidate_cache()
        self._rebuild_all_views()

    def _clip_fit_current(self):
        idx = self.current_layer_idx
        if idx < 0 or idx >= len(self.layers):
            return
        layer = self.layers[idx]
        layer.clip_min = layer.points.min(axis=0).astype(np.float32)
        layer.clip_max = layer.points.max(axis=0).astype(np.float32)
        layer.invalidate_cache()
        self._sync_clip_ui_from_layer()
        self._rebuild_all_views()

    # ---------------- 截图 ----------------
    def export_screenshot(self):
        if not self.layers:
            QMessageBox.information(self, '提示', '还没有加载任何点云。')
            return

        path, _ = QFileDialog.getSaveFileName(
            self, '保存截图', 'pointcloud.png',
            'PNG (*.png);;JPEG (*.jpg *.jpeg);;BMP (*.bmp)'
        )
        if not path:
            return

        try:
            img = self._grab_views()
            if img is None or img.isNull():
                raise RuntimeError('未能获取渲染画面')
            if not img.save(path):
                raise RuntimeError('写入文件失败')
            self.status.showMessage(f'截图已保存: {path}')
        except Exception as e:
            QMessageBox.critical(self, '截图失败', str(e))

    def _grab_views(self):
        images = []
        for v in self.views:
            try:
                img = v.grabFramebuffer()
                if img is not None and not img.isNull():
                    images.append(img)
            except Exception:
                pass

        if not images:
            return self.grab().toImage()

        if len(images) == 1:
            return images[0]

        rows, cols = (1, 2) if len(images) == 2 else (2, 2)
        w = max(im.width() for im in images)
        h = max(im.height() for im in images)

        canvas = QImage(w * cols, h * rows, QImage.Format_RGB32)
        canvas.fill(QColor(*self._bg_color[:3]))

        painter = QPainter(canvas)
        try:
            for i, im in enumerate(images):
                r, c = divmod(i, cols)
                painter.drawImage(c * w, r * h, im)
        finally:
            painter.end()
        return canvas

    # ---------------- 视角 ----------------
    def reset_view(self):
        if self.layers:
            mins, maxs = None, None
            for layer in self.layers:
                if not layer.visible or len(layer.points) == 0:
                    continue
                mn = layer.points.min(axis=0)
                mx = layer.points.max(axis=0)
                if mins is None:
                    mins, maxs = mn, mx
                else:
                    mins = np.minimum(mins, mn)
                    maxs = np.maximum(maxs, mx)

            if mins is not None:
                extent = float(np.max(maxs - mins))
                distance = extent * 1.8 if extent > 0 else 10.0
                center = ((mins + maxs) * 0.5).astype(np.float64)
            else:
                distance, center = 10.0, np.zeros(3)
        else:
            distance, center = 10.0, np.zeros(3)

        # ★ 关键修复:pos 必须是 QVector3D,而不是 numpy 数组
        center_vec = QVector3D(float(center[0]), float(center[1]), float(center[2]))

        for i, v in enumerate(self.views):
            v._emit_enabled = False
            try:
                v.setCameraPosition(
                    pos=center_vec,  # ← 用 QVector3D
                    distance=distance,
                    elevation=22,
                    azimuth=self.view_offsets[i],
                )
            finally:
                v._emit_enabled = True

    # ---------------- 拖拽 ----------------
    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.acceptProposedAction()

    def dropEvent(self, event):
        urls = event.mimeData().urls()
        if not urls:
            return
        for u in urls:
            p = u.toLocalFile()
            if p and os.path.isfile(p):
                self._add_file(p)

    # ---------------- 其他 ----------------
    def show_about(self):
        QMessageBox.information(
            self, '关于',
            '<h3>3D 点云预览器(进阶版)</h3>'
            f'<p>Qt 绑定: {QT_BINDING} | 渲染: pyqtgraph.opengl | 加载: open3d</p>'
            '<p><b>快捷键</b><br>'
            'Ctrl+O 打开 &nbsp;|&nbsp; Ctrl+Shift+O 追加 &nbsp;|&nbsp; '
            'Ctrl+S 截图 &nbsp;|&nbsp; R 重置视角</p>'
            '<p><b>鼠标</b><br>'
            '左键拖动 = 旋转 &nbsp;|&nbsp; 中键/右键拖动 = 平移 &nbsp;|&nbsp; 滚轮 = 缩放</p>'
        )


# ================================================================
# 6. 入口
# ================================================================

def main():
    app = QApplication(sys.argv)
    win = PointCloudViewer()
    win.show()

    for p in sys.argv[1:]:
        if os.path.isfile(p):
            win._add_file(p)

    sys.exit(app.exec() if hasattr(app, 'exec') else app.exec_())


if __name__ == '__main__':
    main()

界面显示如图:

七、总结与展望

这个项目虽然不大,但覆盖了 3D 可视化的几个核心环节:数据加载、坐标变换、颜色映射、OpenGL 渲染、Qt 界面集成、多视图同步。用到的技术都是 Python 生态里成熟且易上手的库,适合作为学习 3D 编程的练手项目。

未来可以继续扩展:

  • 点云配准(ICP):把多片点云对齐。
  • 法线估计与光照:让点云看起来更有立体感。
  • 导出裁剪后的点云:保存为 PLY 等格式。
  • 时间序列动画:按帧播放动态点云。

如果你也想动手做一个,建议从最简单的"加载一个 PLY 并显示"开始,再逐步加上颜色、交互、多视图。遇到问题不要怕,OpenGL 和 Qt 的坑虽然多,但社区资料丰富,踩过去就是经验。

相关推荐
weixin_440401691 小时前
质朴的爬虫+数据处理
爬虫·python·数据分析·pandas
估值探索者1 小时前
【Python量化系统工程实战 #01】数据存储选型 CSVSQLiteMySQL 对比与 SQLite 实战建库
开发语言·jvm·python·sqlite·api接口·数据api接口·股票数据api接口
计算机毕业编程指导师1 小时前
计算机毕设答辩技巧:基于Hadoop+Django的公共交通运营数据分析与可视化系统怎么做 源码 毕业设计 选题推荐 毕设选题 数据分析 机器学习
大数据·hadoop·python·spark·毕业设计·课程设计·交通运行
砚底藏山河1 小时前
量化实战:行情数据 Schema 演进与向后兼容
java·python·金融·maven
苏离~Hack1 小时前
InfoScraper:面向授权目标的一站式资产信息收集工具
python
计算机毕业编程指导师1 小时前
【计算机毕设选题推荐】基于Hadoop+Django高频电力消耗大数据分析系统从0到1 源码 毕业设计 选题推荐 毕设选题 数据分析 机器学习
hadoop·python·数据分析·spark·毕业设计·课程设计·电力
FYKJ_20101 小时前
springboot手工蜀绣在线销售系统60947-计算机课程设计、毕业设计
java·spring boot·后端·python·mysql·spark·课程设计
vx_Biye_Design1 小时前
springboot老年人用药智能管理系统38605-计算机课程设计、毕业设计
java·spring boot·后端·python·django·课程设计·express
сокол1 小时前
【Python-基础-环境搭建】Ubuntu Python 开发环境搭建:编译、pipenv、pyenv、远程连接
开发语言·python·ubuntu