ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PySide2+RealSense深度相机实时显示:从线程模型到性能优化

PySide2+RealSense深度相机实时显示:从线程模型到性能优化

做视觉相关开发的朋友,大概率都遇到过这个场景:手里一台RealSense深度相机,想快速搭一个桌面界面把深度数据实时看起来,而不是每次都在命令行里跑官方Demo。我之前为了调试一组抓取算法,需要一边看深度图一边调整参数,折腾了几个方案,最后用PySide2很干净地解决了这个需求——把RealSense的深度流和彩色流实时显示在QWidget界面上,顺带还能叠加一些调试信息。

这篇文章就是把这个过程完整拆一遍:从环境准备、线程模型、核心代码到性能调优、常见坑位,全部摊开来说。适合两类人,一是刚接触PySide2和RealSense、想快速跑通实时显示的朋友,二是已经在做视觉工具开发、想把相机预览集成到现有桌面程序里的老手。读完你至少能照着搭出一个可用的实时预览窗口,并且知道后续怎么扩展成点云、录制、参数调节这些高级功能。

别小看这个“实时显示”,它其实涵盖了C++后端、Python绑定、GUI线程模型、图像格式转换、性能优化等好几个层面的问题,任何一个环节没处理好都会导致界面卡顿或者程序崩溃。我一开始以为就是“读取一帧—转成图片—贴到控件上”这么简单,实际做下来发现水还挺深,尤其是多线程和帧率控制这两块,后面会单独拿出来讲。

1. 项目概述与整体思路

1.1 这个项目到底做了什么

从最朴素的需求说起:RealSense相机默认的调试工具是官方自带的那套可视化程序,功能很全,但它是独立软件,没法嵌入到我们自己的业务系统里。我想要的是一个能在自己的GUI程序里实时刷新深度帧画面的组件,数据还要能拿到手里做后续处理,比如计算物体距离、检测平面、生成点云等。

最终的工具落地是这样的:一个PySide2窗口,左边显示深度图,右边显示彩色图,顶部有几个下拉框来回切换分辨率、帧率,底部留了一块状态栏实时显示FPS和相机温度。深度图做了伪彩色映射,方便肉眼看距离层次,彩色图就是原始RGB流。整个程序跑起来以后,深度的实时预览延迟能控制在120毫秒以内,CPU占用在四核桌面上大约在25%左右,完全能边看深度图边跑其他算法逻辑。

这个项目看似简单,但它其实是很多视觉应用的公共底座。做机器人抓取、人脸识别、安防监控、体感交互,几乎都需要“一个桌面界面把相机数据实时展示出来”。把这块地基打牢了,后面接什么算法都顺。

1.2 方案选型:为什么是PySide2而不是OpenCV窗口

先说一个最常见的误区:很多人拿OpenCV的imshow来显示深度图,省事是真省事,但问题也很明显——OpenCV的高GUI模块是基于HighGUI的,它只能弹出一个独立窗口,没法跟业务界面深度融合,更别说做自定义控件、按钮、滑动条这些交互元素。想要一个真正能集成进项目的界面,还是得用正式的GUI框架。

当时我对比了三个方案:PyQt5、PySide2、Tkinter。Tkinter先被排除了,它对图像渲染的支持太弱,做视频流显示性能不行,界面也简陋。PyQt5和PySide2其实是同一个东西的两种绑定,两者API几乎一致,但PySide2的授权是LGPL,对商业项目更友好,而且官方文档质量不错,所以我最终选了PySide2。实际用下来,PySide2的信号槽机制和QImage无缝衔接,跟相机帧回调这种事件驱动的场景非常契合。

另外补一句,如果你用PySide6也是可以的,API变化不算大,我下面贴的代码只需要把导入语句从PySide2改成PySide6就能跑通。不过我当时用的还是PySide2,所以本文代码按PySide2来写,后面就不再重复提PySide6的事了。

2. 环境准备与依赖安装

2.1 需要哪些依赖包

正式开始写代码之前,先把环境装好。这个项目依赖不多,但版本之间容易打架,我列一下我当时用的组合:

  • Python 3.8
  • PySide2 5.15.2
  • pyrealsense2 2.54.2
  • opencv-python 4.8.0(其实不是必须,但我用来做深度图伪彩色映射)
  • numpy 1.24

这里稍微解释一下为什么需要numpy。RealSense SDK返回的深度帧本质上是一份uint16数组,每个像素值代表该点到相机的距离(毫米单位)。这个原始数据不能在GUI里直接显示,必须做两步处理:先归一化到0~255的灰度范围,再做伪彩色映射变成三通道RGB图。这两步都依赖numpy做数组操作,所以numpy是刚需,跑不掉。

安装命令很直白,一条pip搞定大部分:

pip install pyside2==5.15.2 pyrealsense2 numpy opencv-python

2.2 最容易踩的安装坑

pyrealsense2这个包是通过PyPI发布的Python绑定,它内部会动态链接RealSense的底层库,所以装完以后不需要再手动装SDK。但有一个老坑:Windows系统下,如果安装时提示缺少某个DLL,多半是Visual C++运行库没装,去微软官网装一下最新的“Visual C++ Redistributable”就能解决。

还有一个比较隐蔽的问题:不要把pyrealsense2装在虚拟环境外。我身边有位做机器人项目的朋友,为了省事直接在系统Python里装了所有包,结果Twitter上某个第三方工具升级时把他系统的numpy版本给改了,整个RealSense程序直接跑不起来,最后排查了好久才发现是numpy版本被顶掉了。所以强烈建议用虚拟环境,隔离干净。

装好之后可以用一个简单命令验证相机和SDK是否正常:

import pyrealsense2 as rs ctx = rs.context() print(len(ctx.devices)) # 输出设备数量,非0说明识别到了相机

如果这里输出是0,先别急着写界面代码,大概率是USB驱动问题,这个在后面的排查章节再细说。

3. 核心架构:线程模型与数据流转

3.1 为什么不能直接在GUI线程里读帧

这是整个项目最关键的一个设计决策。很多人第一次写RealSense显示程序,本能地会这样写:开一个while循环,每次循环从Pipeline里wait_for_frames,然后把图像贴到QLabel上。这样写在小分辨率和低帧率下看起来也能跑,但一旦把分辨率调高或者同时显示两路流,界面就会卡顿,拖动窗口都费劲。

原因很简单:RealSense的帧读取是一个I/O密集型操作,wait_for_frames会阻塞线程等待相机数据到来。如果把它放在GUI主线程里,主线程被阻塞,界面就没法及时处理鼠标事件、重绘事件,表现出来就是“卡死了”。而且现代桌面应用的GUI线程还要处理各种消息循环,任何长时间操作都会破坏交互体验。

正确的做法是把相机读取放到一个独立的工作线程里,通过信号槽机制把图像数据送回GUI线程。这个思路跟UI编程里经典的“后台任务线程化”完全一致:耗时的、可能阻塞的操作一律不进主线程,主线程只负责界面调度。

3.2 信号槽是处理视频流的最佳搭档

PySide2的信号槽机制特别适合这种场景。工作线程里每拿到一帧,就emit一个信号,把图像数据作为参数传出去;GUI线程这边连接这个信号,在槽函数里把图像画到界面上。信号槽是跨线程安全的,PySide2内部会自动处理线程之间的数据传递,不需要我们自己去加锁。

这里有一点要注意:信号传递的图像数据量可能很大,如果每帧都直接传QImage对象,频繁的跨线程拷贝开销很可观。我当时的设计是传递一个numpy数组的引用,然后在GUI线程里再转成QImage。numpy数组在Python层面传引用,不会发生真正的内存拷贝,效率高很多。当然,这样做的前提是工作线程不会在发送信号后再修改这个数组,否则GUI线程读到的可能是脏数据。我的做法是每一帧都新建一个数组,宁可多花一点内存分配的时间,也不冒数据竞争的风险。

整个数据流是这个样子的:

  1. 工作线程调用Pipeline.wait_for_frames拿到一帧
  2. 从帧里取出深度数据和彩色数据,分别转成numpy数组
  3. 深度数组做归一化和伪彩色,生成可显示的RGB数组
  4. 发送带图像的信号
  5. GUI线程的槽函数接收数组,转成QImage,setPixmap到QLabel上

3.3 QThread的两种用法

PySide2里QThread一般有两种用法,一种是继承QThread重写run方法,另一种是把一个普通QObject对象通过moveToThread移入QThread。新手经常用第一种,但对这个项目我更推荐第二种,理由在于:moveToThread方式可以让工作对象中的槽函数在子线程的event loop里执行,这样你可以在不重启线程的情况下,通过信号动态控制工作线程的行为,比如切换分辨率、停止采集。而继承QThread重写run的方式,更像一个一次性执行的任务,动态控制要写更多样板代码。

当然这不是绝对的,如果你想快速出效果,继承QThread也可以,代码更直白。文章后面给的实现示例我用的就是moveToThread的方式,因为我需要在界面上动态切换分辨率,这种方式更灵活。

4. 核心代码实现:从读取到显示

4.1 相机初始化与Pipeline配置

先看最基础的相机初始化代码。我封装了一个CameraWork类,负责所有和RealSense设备相关的操作:

import pyrealsense2 as rs import numpy as np from PySide2.QtCore import QObject, Signal, Slot class CameraWork(QObject): frame_ready = Signal(object, object) # 参数:深度图数组、彩色图数组 def __init__(self, width=640, height=480, fps=30): super().__init__() self.width = width self.height = height self.fps = fps self.pipeline = None self.running = False @Slot() def start(self): self.pipeline = rs.pipeline() config = rs.config() config.enable_stream(rs.stream.depth, self.width, self.height, rs.format.z16, self.fps) config.enable_stream(rs.stream.color, self.width, self.height, rs.format.bgr8, self.fps) self.pipeline.start(config) self.running = True while self.running: frames = self.pipeline.wait_for_frames() depth_frame = frames.get_depth_frame() color_frame = frames.get_color_frame() if not depth_frame or not color_frame: continue depth_image = np.asanyarray(depth_frame.get_data()) color_image = np.asanyarray(color_frame.get_data()) self.frame_ready.emit(depth_image, color_image) @Slot() def stop(self): self.running = False if self.pipeline: self.pipeline.stop()

这段代码里有两个细节需要说明。第一,彩色流格式选的BGR8而不是RGB8,这是为了让数据可以直接被OpenCV处理,后面做伪彩色映射时我用的OpenCV的applyColorMap,它接受BGR格式输入。如果你不打算用OpenCV,用RGB8也可以,只是后面转QImage时要多写一步格式转换。第二,wait_for_frames默认会等待两路流都准备好才返回,这意味着帧率以最慢的一路为准,配置彩色和深度同分辨率同帧率时,基本能达到标称帧率。

4.2 线程启动和界面桥接

相机工作对象要在QThread里跑,界面这边需要做桥接。这段代码放在主窗口类里:

from PySide2.QtWidgets import QMainWindow, QLabel, QVBoxLayout, QWidget from PySide2.QtCore import QThread, Qt class MainWindow(QMainWindow): def __init__(self): super().__init__() self.label_depth = QLabel() self.label_depth.setAlignment(Qt.AlignCenter) self.label_color = QLabel() self.label_color.setAlignment(Qt.AlignCenter) # ... 布局代码省略 ... self.thread = QThread(self) self.camera_work = CameraWork() self.camera_work.moveToThread(self.thread) self.thread.started.connect(self.camera_work.start) self.camera_work.frame_ready.connect(self.on_frame_ready) self.thread.start() def on_frame_ready(self, depth_image, color_image): # 此处将图像转成QImage并显示到控件上 pass def closeEvent(self, event): self.camera_work.stop() self.thread.quit() self.thread.wait() super().closeEvent(event)

这个桥接是核心,注意几点:线程对象thread必须是主窗口的子对象,这样窗口关闭时线程才会随窗口一起销毁;closeEvent里一定要先调用camera_work.stop()再退出线程事件循环,否则线程会卡在wait_for_frames里无法正常退出,程序关了进程都退不干净。

4.3 深度图到伪彩色图像的转换

这是显示效果的关键步骤。原始的深度数组是uint16,一个像素值可能就是几千毫米,直接把数值当灰度显示的话,远处的物体几乎全白,近处的物体又全黑,什么层次都看不到。所以要做一个映射。

常见的做法是动态范围压缩:取当前帧深度值的最小值和最大值,线性映射到0到255。但直接对整帧做min/max有一个问题:如果场景里有一个特别近的物体(比如手指在镜头前划过),整个图像的对比度会被它拉偏。我后来改成了固定范围映射,比如把200毫米到3000毫米映射到0到255,超出范围的截断。这样显示效果稳定得多,尤其适合固定安装的相机。

伪彩色用的是OpenCV的applyColorMap,把灰度图映射到COLORMAP_JET之类的彩色表。虽然从数据角度看深度图还是单通道,但人的视觉系统对颜色的层次感知远强于灰度,伪彩色之后能非常直观地看出远近关系。映射代码大概是:

import cv2 def depth_to_pseudocolor(depth_image): depth_8u = np.clip((depth_image - 200) / 2800 * 255.0, 0, 255).astype(np.uint8) color = cv2.applyColorMap(depth_8u, cv2.COLORMAP_JET) return color

4.4 QImage转换的最后一步

numpy数组要变成能显示的QImage,这一步的格式转换很容易出错。彩色图像我用的BGR8格式,QImage默认不认BGR,所以必须先转成RGB:

def numpy_to_qimage(arr_bgr): arr_rgb = cv2.cvtColor(arr_bgr, cv2.COLOR_BGR2RGB) h, w, ch = arr_rgb.shape bytes_per_line = ch * w qimg = QImage(arr_rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) return qimg.copy()

注意最后的copy()。QImage如果直接引用numpy数组的数据指针,而数组是工作线程传入的临时对象,槽函数执行完之后数组可能被垃圾回收,QImage就成了野指针,显示出来是花屏或者直接崩溃。加一个copy()让QImage拥有自己的数据副本,虽然多了一次内存拷贝,但安全第一。后来我实测发现,做了copy之后帧率损失并不大,在640x480分辨率下完全感觉不到。

5. 性能优化:从能用飙到流畅

5.1 先认清瓶颈在哪

代码能跑了之后,我最先注意到的问题是CPU占用高和偶发卡顿。用性能分析工具看了下,发现瓶颈根本不在SDK读取上,而是GUI刷新这块。原始实现是每帧到了就立刻setPixmap,加上伪彩色转换、QImage拷贝,每一帧的所有处理都在槽函数里串行完成,一次处理完也就十几毫秒,但架不住每秒钟30次,CPU就上去了。

这个问题的根源在于:视频显示这类场景,不需要每一帧都处理。人眼对动态图像的感知也就每秒二三十帧,再高的帧率感知不出来,反而白白消耗CPU。所以我做了一个丢帧策略:工作线程照常以30帧读取,但GUI槽函数里判断距上次刷新是否已经过了33毫秒,不满就直接return,相当于把显示帧率锁在30。这个策略对CPU的降低效果非常明显。

5.2 减少跨线程数据拷贝

前面提到过,信号传numpy数组引用,这步不拷贝。但槽函数里的np.clip、cv2.applyColorMap、cv2.cvtColor、QImage.copy,每一步都有拷贝,整条链路加起来,每帧要额外拷贝好几份图像大小的数据。在640x480分辨率下还好,一旦上到1280x720,每帧的内存流量就上了一个量级。

我做了两个优化:一是深度图的伪彩色转换放到工作线程里做,信号直接传已经转换好的三通道RGB图,这样GUI线程就不用再碰深度数据了;二是把QImage的copy也优化掉——改为在CameraWork内部用一个循环数组缓存最近几帧的numpy数组,保证数组在信号发出后有足够长的生命周期,这样槽函数里可以直接引用数组数据构造QImage,不用copy也能安全显示。这个优化属于进阶玩法,新手阶段用copy就够了,等真觉得性能紧张再回头做。

5.3 分辨率和帧率的取舍

RealSense相机支持很多种分辨率组合,但不是分辨率越高越好。D435i这类相机在深度和彩色同时开启时,如果选太高分辨率,帧率会显著下降,而且对USB带宽的要求也高。我后来在界面上做了几个预设档:640x480@30、848x480@30、1280x720@15。调试阶段用低分辨率档位,正式跑算法再切到需要的档位,这样兼顾了实时预览和算法精度。

另外,USB接口的选择也很关键。RealSense相机对USB带宽非常敏感,我亲测过同样一台电脑,插USB 2.0口和USB 3.0口完全是两种体验:3.0口能稳跑30帧,2.0口在1280x720下频繁丢帧。如果发现帧率总是上不去,先去检查插的口是不是蓝色的USB 3.0口,这个坑太常踩了。

6. 常见问题与排查技巧实录

6.1 相机识别不到

这个问题排在所有问题的第一位。代码还没跑就发现ctx.devices是空的,可能原因有几类:USB线只接了供电没通数据、USB 3.0的扩展卡驱动没装好、相机固件版本过老。我的经验是先换一根短一点的USB线试,RealSense对线材质量挺挑剔,长线信号衰减严重。排除了线材问题再考虑驱动。

还有一个不太容易想到的点:某些电脑上如果装了虚拟机软件,宿主机的USB控制器可能会被虚拟机抢占,导致RealSense识别不到。关掉虚拟机再插一次往往就解决了。

6.2 界面刷新卡顿

程序能跑但界面卡,重点排查三件事:摄像头是否在主线程读取(应该在工作线程);槽函数里是否有重计算(应该提前算好);QLabel的大小是否和图像分辨率匹配(如果不匹配,每帧都要缩放,非常吃CPU)。最后一个问题我最初没意识到,后来发现窗口拉大了之后QLabel自动缩放了图像,而且缩放滤镜默认是平滑的,等于每帧多了一次全图插值运算。我把QLabel的setScaledContents设为False,直接让控件大小跟随图像分辨率走,卡顿立刻缓解。

6.3 关闭窗口时程序假死

这个坑几乎人人都会踩:点了关闭按钮,界面关了但进程还在后台,或者直接卡在退出界面。根本原因是线程没有正确收尾。wait_for_frames是个阻塞调用,如果线程正在里面等着,你用thread.quit()是没用的,它根本不会响应退出请求,必须先把pipeline停掉让阻塞返回。

我在closeEvent里的处理顺序是:先把camera_work.running置为False,但这样还不够,因为如果刚好卡在wait_for_frames里,running是False也无法让它立刻返回。最可靠的办法是调用pipeline.stop(),它会主动中断阻塞,让工作线程退出while循环。另外还可以给wait_for_frames加超时参数,比如wait_for_frames(5000),这样即使异常也不会无限期卡住。我把这个超时也加上了,算是双保险。

6.4 常见问题速查表

现象可能原因解决办法
相机识别不到USB线/口问题或驱动缺失换短USB3.0线,检查驱动
帧率极低USB 2.0带宽不足换USB 3.0口
深度图全黑或全白未做归一化或范围映射错误用固定范围映射,如200~3000mm
界面卡顿主线程读取相机移动工作线程
关闭后进程残留未停止PipelinecloseEvent里先stop再quit
伪彩色颜色异常BGR/RGB通道顺序搞混转换前先确认通道顺序
内存持续增长QImage未copy或数组被持有用循环缓存或加copy

6.5 实测中的几个独家心得

第一,调试阶段把FPS打印到状态栏而不是print到控制台,因为高帧率的print本身就会拖慢程序。第二,深度相机的帧和彩色帧并不总是严格对应时间的,如果你后面要做深度和彩色融合,需要开启深度对齐功能,这个问题早做比晚做好。第三,如果你在界面上还要显示温度之类的传感器数据,记得温度获取不是在每帧都做,温度变化的频率远低于帧率,单独开一个定时器1秒刷新一次就够了。

还有一个挺有用的技巧:开发时把相机的启动参数做成可配置的,哪怕是硬编码在常量里,也要集中放在文件头部,别散落在代码中间。因为相机参数改动频率非常高,调分辨率、调曝光、调增益,每次都要改代码,集中管理能省不少事。

7. 进阶扩展:一个显示小工具能走多远

深度相机实时显示只是起点,我在做完这个基础工具后,陆陆续续加了不少功能,这里挑几个方向说一下,都是基于上面这套框架可以直接扩展的。

一是深度对齐(align),把深度图对齐到彩色图坐标系,这样每个深度像素和彩色像素一一对应,才能做后续的像素级融合。RealSense SDK里提供了rs.align类,用法很简单,在取帧之后先对齐再分别取深度和彩色,但要注意对齐操作本身有少量计算开销,开启后帧率会略微下降。

二是录制与回放。RealSense SDK支持把rosbag格式的数据包录制下来,代码里只需要在pipeline.start之前加一个record路径。录下来的数据包可以用同一个SDK回放,对算法调试非常有用——不用每次都在真实相机前跑,拿着数据包离线调参数效率高多了。

三是点云生成。深度相机最大的价值就是能拿到3D信息,把深度帧转成点云是很多机器人项目的标配。SDK里直接提供了pointcloud类,可以从深度帧计算点云,并用Open3D做可视化。我后来把点云预览也做进了同一个界面里,用matplotlib的3D散点图显示降采样后的点云,虽然实时性一般,但调试已经够用。

四是参数调节面板。RealSense的深度相机有很多可调参数,比如激光功率、曝光时间、增益等。不同光照条件下这些参数对深度质量影响很大。我在界面上加了一个QGroupBox,用滑动条实时调整这些参数,通过管道发送给相机。实测下来,调整曝光时间对室内深度质量的影响最明显,在强光场景下把曝光调低,深度空洞明显减少。

如果你只是需要一个简单的预览工具,做到第四节就已经够了;但如果你在视觉行业长期混,我强烈建议把第五节到第七节这些点都过一遍,它们几乎覆盖了所有视觉桌面工具开发的通用问题,后面做别的相机项目同样用得上。

最后再分享一个小技巧:如果你要在这个基础上做算法实验,不要把算法逻辑写进CameraWork里。保持CameraWork只做采集和显示,算法作为一个独立模块接收深度数组,这样你换算法、调参数都不影响采集线程,结构干净很多。我在实际开发中吃过这个亏——最早把深度滤波算法直接写在了采集循环里,后来换滤波方式时改得头疼。把采集、显示、算法三层拆开,各干各的事,整个项目维护起来轻松太多了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进