从零构建一个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 值"说话"
如果点云没有颜色,怎么让它好看又信息丰富?按高度着色是最常用的手法。原理很简单:
- 找出 Z 的最小值和最大值。
- 把每个点的 Z 归一化到
[0, 1]。 - 用
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,把文件直接拖进窗口就能加载。
六、踩过的坑
QVector3D类型错误
pyqtgraph 的opts['center']必须是QVector3D,如果传 numpy 数组,内部调用.x()就会崩。解决:在reset_view里显式转成QVector3D。- 导入笔误
QPainte少了个r,导致 PySide6 导入失败,又去回退到未安装的 PyQt5。仔细检查拼写即可。 - 侧边栏显示不全
QSplitter默认可以拖到很窄,把内容裁掉。给左侧容器设置setMinimumWidth(340),内部left也设最小宽度,就稳了。 - 性能问题
大点云不加下采样会卡死。用步进切片下采样,并给图层加缓存,避免切换颜色模式时重复计算。
具体代码如下:
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 打开 | Ctrl+Shift+O 追加 | '
'Ctrl+S 截图 | R 重置视角</p>'
'<p><b>鼠标</b><br>'
'左键拖动 = 旋转 | 中键/右键拖动 = 平移 | 滚轮 = 缩放</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 的坑虽然多,但社区资料丰富,踩过去就是经验。