搞机器人和三维视觉的人,八成绕不开深度相机。我最早拿到的就是奥比中光Astra Pro,后来项目里又换了Astra Pro SM,幸好手上的采集代码基本没动,因为整套逻辑用的都是OpenNI2。这篇文章就是把我在 Windows x64、Linux x64 和 Linux arm64 三个平台上,用 OpenNI2 获取这两款相机深度图的过程完整复盘一遍,包括环境配置、核心代码、编译链接,以及那些文档里不会写给你的坑。如果你正准备给机器人导航、三维重建或者体感交互项目选一套深度图采集方案,这篇文章可以帮你少走不少弯路。
1. 为什么用OpenNI2而不用厂商SDK
1.1 OpenNI2到底是什么
OpenNI2,全称 Open Natural Interaction 第二版,是一套跨平台的深度传感器访问框架。最早由 PrimeSense 推动,后来以开放源码的形式持续维护。它定义了一套统一的 C++ API,上层应用用同一套代码对接不同厂家的深度相机,底层通过插件机制加载厂商驱动。对于奥比中光 Astra 系列相机,厂商在 SDK 里提供了基于 OpenNI2 的驱动,所以你可以把 OpenNI2 理解成“深度相机的通用 USB 协议层”,而相机驱动就是“翻译官”。
这里必须强调一个坑:OpenNI2 和 2009 年那个 OpenNI 1.x 完全不是一回事,API 几乎是推倒重写的。网上很多老教程、老开源项目用的都是 OpenNI 1.x 的接口,什么xn::Context、xn::DepthGenerator,拿到 OpenNI2 下面根本编译不过。我自己刚入门时就被这个坑过,所以看教程之前先确认版本。
1.2 对比厂商SDK,选型依据是什么
你可能会问,奥比中光不是有自己的 SDK 吗?为什么非要用 OpenNI2?我在做方案选型时主要看三点:
第一,跨平台一致性。同一个 C++ 程序,Windows、Linux x64、Linux arm64 上只要换一套库文件,源码一行都不用改。厂商 SDK 虽然也跨平台,但接口风格、依赖方式在不同版本之间变化更大,维护成本高。
第二,换设备成本低。今天项目里插的是 Astra Pro,明天换成 Astra Pro SM,甚至换 PrimeSense、华硕 Xtion 这类老设备,只要底层驱动支持,上层代码依然复用。我自己实际测试过,Astra Pro 和 Astra Pro SM 在 OpenNI2 层面的接口完全一致,程序里甚至可以通过device.getDeviceInfo().getName()拿到当前设备名来做区分,但采集流程不用改。
第三,资料多、生态稳。OpenNI2 作为开放框架,配套的教程、开源项目比厂商 SDK 多,遇到问题搜索时有现成经验可以抄。尤其是 Linux 下的权限问题、USB 带宽问题,网上讨论非常充分。
当然 OpenNI2 也不是万能的,比如官方主干版本停在 2.2,很多新硬件支持要靠厂商“补丁版”SDK;另外深度图和彩色图对齐这类功能,OpenNI2 原生接口不提供,得自己处理。我的习惯是:核心深度采集用 OpenNI2,彩色对齐和算法部分再用 OpenCV 或者厂商 SDK 混合补。
2. 环境搭建:三个平台一次配好
2.1 Windows平台配置步骤
Windows 下的配置一般是最省心的。首先到奥比中光官网的下载页面,找到对应 Astra 系列的 OpenNI2 SDK 包。下载后解压,你会看到 Include、Redist、Samples 这几个关键目录。Include 里面是 OpenNI.h 头文件,Redist 里面是运行时库,包括 OpenNI2.dll、OpenNI2.lib 以及一些设备配置文件。
接下来把相机用 USB 线插到电脑上。打开设备管理器,如果能看到一个 Orbbec 或者 Astra 相关的设备,说明驱动已经自动装好了。Windows 10/11 一般会自动识别,不需要手动安装驱动。如果设备管理器里显示的是带问号的未知设备,就要去官网下载对应的 USB 驱动包手动安装。
开发环境我用的是 Visual Studio 2019 和 2022。新建一个空 C++ 项目后,需要做几步配置:项目属性里把 Include 目录加到“VC++ 目录”的包含目录;把 Redist 目录加到库目录;链接器输入里加上 OpenNI2.lib。还要注意把运行平台切到 x64,因为默认的 Win32 平台会让你链接一个根本不存在的 32 位库。程序编译出来之后,记得把 Redist 里的 OpenNI2.dll 拷贝到 exe 同一目录下,否则运行时马上报找不到 DLL。
2.2 Linux x64 环境配置细节
Linux 下稍微麻烦一点,但也就多两步:依赖安装和 udev 规则。以 Ubuntu 20.04 为例,先执行:
sudo apt update sudo apt install -y libusb-1.0-0-dev udev然后解压厂商提供的 Linux x64 版 OpenNI2 包。包里面通常会带一个orbbec-usb.rules或者类似名字的 udev 规则文件。把它复制到系统目录:
sudo cp orbbec-usb.rules /etc/udev/rules.d/ sudo udevadm control --reload sudo udevadm trigger这个步骤非常关键,如果漏掉,程序在打开设备时大概率会报权限错误。把当前用户加入plugdev或dialout组也会有帮助:
sudo usermod -aG plugdev $USER改完用户组要重新登录一下才能生效。程序编译时,使用 g++ 指定头文件和库路径,例如:
g++ -o depth_viewer main.cpp \ -I./OpenNI2/Include \ -L./OpenNI2/Redist -lOpenNI2 \ -lpthread -lrt运行时需要让系统找到动态库,一条命令搞定:
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./OpenNI2/Redist ./depth_viewer注意有些 SD 卡或网络文件系统不支持动态库加载,最好把 Redist 目录放到本地磁盘再运行。
2.3 Linux arm64 平台配置要点
arm64 平台主要是嵌入式设备和开发板,比如 RK3399、瑞芯微方案,或者 NVIDIA Jetson 系列。奥比中光官网通常同样提供 Linux arm64 的 OpenNI2 包,架构标识一般是 aarch64。拿到包之后,头文件目录和 x64 版本完全一样,只有 Redist 里的库文件是 arm64 架构的。
我测试的板子是 RK3399,系统是 Ubuntu 18.04 arm64。配置流程和 x64 几乎一致:装 libusb、放 udev 规则、编译时指定 aarch64 的库路径。编译命令注意加-std=c++11,有些旧板子的默认 g++ 版本比较低,不加会报一些奇怪错误。
如果你在官网找不到 arm64 包,两个办法:一是找厂商 FAE 要,他们一般有内部编译版本;二是自己下源码交叉编译,但这个成本高,不推荐。我实测用官方 arm64 包在 RK3399 上跑 640x480@30 深度流非常稳定,CPU 占用也不高,大约 20% 左右。
3. 核心代码:从初始化到拿到一帧深度图
3.1 OpenNI2 API调用流程
OpenNI2 的核心 API 调用流程可以总结成五步:初始化、打开设备、创建流、启动流、循环读帧。代码结构很清晰,我直接给出一个可用的最小示例,里面加了详细注释:
#include <OpenNI.h> #include <iostream> using namespace openni; int main() { // 1. 初始化OpenNI2运行时 Status rc = OpenNI::initialize(); if (rc != STATUS_OK) { std::cerr << "初始化失败: " << OpenNI::getExtendedError() << std::endl; return -1; } // 2. 打开默认设备 Device device; rc = device.open(ANY_DEVICE); if (rc != STATUS_OK) { std::cerr << "打开设备失败: " << OpenNI::getExtendedError() << std::endl; OpenNI::shutdown(); return -1; } // 3. 创建深度流 VideoStream depthStream; rc = depthStream.create(device, SENSOR_DEPTH); if (rc != STATUS_OK) { std::cerr << "创建深度流失败: " << OpenNI::getExtendedError() << std::endl; device.close(); OpenNI::shutdown(); return -1; } // 4. 启动深度流 rc = depthStream.start(); if (rc != STATUS_OK) { std::cerr << "启动深度流失败: " << OpenNI::getExtendedError() << std::endl; depthStream.destroy(); device.close(); OpenNI::shutdown(); return -1; } // 5. 循环读帧 VideoFrameRef frame; while (true) { rc = depthStream.readFrame(&frame); if (rc != STATUS_OK) { std::cerr << "读取深度帧失败: " << OpenNI::getExtendedError() << std::endl; continue; } int w = frame.getWidth(); int h = frame.getHeight(); const DepthPixel* pDepth = (const DepthPixel*)frame.getData(); if (pDepth) { int cx = w / 2; int cy = h / 2; DepthPixel centerDist = pDepth[cy * w + cx]; std::cout << "中心点距离: " << centerDist << " 毫米" << std::endl; } if (std::cin.get() == 'q') break; } // 6. 清理资源 depthStream.stop(); depthStream.destroy(); device.close(); OpenNI::shutdown(); return 0; }这段代码就是最核心的骨架。拿到VideoFrameRef之后,getData()返回的是深度像素数组,每个元素是DepthPixel类型,本质上就是uint16_t,单位是毫米。如果某个像素值是 0,表示这个点无效,可能原因是距离太近、太远,或者物体表面反光导致结构光解算失败。
3.2 设置分辨率和像素格式
有时候你需要按指定分辨率采集,比如 320x240 或者 1280x960。OpenNI2 允许在启动流之前设置VideoMode:
VideoMode vm; vm.setResolution(640, 480); vm.setFps(30); vm.setPixelFormat(PIXEL_FORMAT_DEPTH_1_MM); depthStream.setVideoMode(vm);但这里有个重要前提:不是所有分辨率都支持。Astra Pro 和 Astra Pro SM 支持的深度模式不完全一样,不同 SDK 版本也可能不同。最稳妥的办法是直接把设备支持的所有模式打印出来,再选一个合适的:
const SensorInfo* info = depthStream.getSensorInfo(); const Array<VideoMode>& modes = info->getSupportedVideoModes(); for (int i = 0; i < modes.getSize(); ++i) { std::cout << modes[i].getResolutionX() << "x" << modes[i].getResolutionY() << " @ " << modes[i].getFps() << " fps, pixelFormat=" << modes[i].getPixelFormat() << std::endl; }我实测 Astra Pro 在 OpenNI2 下通常支持 320x240@30 和 640x480@30 两种深度模式。PIXEL_FORMAT_DEPTH_1_MM是默认像素格式,每个深度值直接代表毫米。有些 SDK 版本还支持PIXEL_FORMAT_DEPTH_100_UM,这种情况下存储的值要除以 10 才是毫米,千万别混淆。
3.3 深度图转可视化与数据读取
如果你想把深度图显示出来,直接用 16 位灰度值是看不到东西的,因为深度范围集中在某个区间,直接显示会一片漆黑。最常见的做法是先缩放再转 8 位:
#include <opencv2/opencv.hpp> // 假设已经拿到VideoFrameRef frame int w = frame.getWidth(); int h = frame.getHeight(); const DepthPixel* pDepth = (const DepthPixel*)frame.getData(); cv::Mat depth16(h, w, CV_16UC1, (void*)pDepth); // 方法一:把0~8000mm映射到0~255 cv::Mat depth8u; depth16.convertTo(depth8u, CV_8U, 255.0 / 8000.0); cv::imshow("Depth", depth8u); // 方法二:先截断最大距离,再归一化 cv::Mat depthTruncated; cv::threshold(depth16, depthTruncated, 8000, 8000, cv::THRESH_TRUNC); depthTruncated.convertTo(depth8u, CV_8U, 255.0 / 8000.0);方法二我用的更多,因为结构光深度相机在远距离时噪声很大,直接把 8000mm 以上的像素都截断成 8000,图像看起来更干净。DepthPixel数组的访问方式就是普通二维数组,pDepth[y * width + x],取出来的值就是该点到相机平面的距离,单位毫米。这里要说清楚,深度值表示的是沿光轴方向的“平面距离”,不是欧氏距离,计算点云的时候需要结合相机内参做逆投影。
4. 完整示例工程与跨平台编译
4.1 工程目录与CMake写法
为了让你能直接抄作业,我给出一个完整的工程目录结构。这个结构我实际在三个平台上都验证过,改动最小:
depth_viewer/ ├── CMakeLists.txt ├── main.cpp ├── third_party/ │ └── OpenNI2/ │ ├── Include/ │ │ └── OpenNI.h │ └── Redist/ │ ├── libOpenNI2.so # Linux │ └── OpenNI2.dll/.lib # WindowsCMakeLists.txt 可以这样写:
cmake_minimum_required(VERSION 3.10) project(depth_viewer) set(CMAKE_CXX_STANDARD 11) find_package(OpenCV REQUIRED) set(OpenNI2_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/third_party/OpenNI2/Include") set(OpenNI2_REDIST_DIR "${CMAKE_CURRENT_SOURCE_DIR}/third_party/OpenNI2/Redist") include_directories(${OpenNI2_INCLUDE_DIR}) add_executable(depth_viewer main.cpp) target_link_libraries(depth_viewer PRIVATE ${OpenCV_LIBS}) if(WIN32) target_link_libraries(depth_viewer PRIVATE "${OpenNI2_REDIST_DIR}/OpenNI2.lib") else() target_link_libraries(depth_viewer PRIVATE "${OpenNI2_REDIST_DIR}/libOpenNI2.so") endif()在 Windows 上编译完成后,记得把OpenNI2.dll拷贝到 exe 所在目录。在 Linux 上不太需要拷贝,因为 CMake 写的是绝对路径,运行时直接用LD_LIBRARY_PATH指向 Redist 目录即可。如果可执行文件要部署到其他机器,则要把libOpenNI2.so一起带上,并放到系统库路径或者程序旁的lib目录。
4.2 三个平台编译实战记录
Windows 下直接在 Visual Studio 打开 CMake 工程,选择 x64 配置,生成解决方案。我遇到最多的坑是平台选成了 x86,然后链接报错找不到 OpenNI2.lib。这个没什么好技巧,就是项目属性里把活动解决方案平台改成 x64。
Linux x64 下执行:
mkdir build && cd build cmake .. make -j4然后运行:
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:../third_party/OpenNI2/Redist ./depth_viewerarm64 板子上的编译命令基本相同,只不过 CMake 需要指定工具链或者直接在板子上原生编译。我的建议是直接在板子上原生编译,交叉编译容易出现动态库路径、头文件路径对不上的问题。Jetson 这类设备上如果 JetPack 里带了 OpenCV,CMake 会自动找到,省事很多。
4.3 运行结果与参数检查
程序跑起来后,控制台会一直在打印中心点距离。把相机对准人,距离大概在 1 到 1.5 米时,中心点数值会稳定在一个小范围内波动,说明深度流正常。如果数值始终是 0,先别急,有几种典型情况:物体距离相机太近,低于最小工作距离;物体表面是强反光材质;或者红外镜头被遮挡。
你也可以在代码里加一个模式打印函数,把所有支持的 VideoMode 打出来,确认当前设备到底支持哪些分辨率和帧率。我遇到过一种情况:Astra Pro SM 在某个固件版本下,320x240 只支持 25fps 而不支持 30fps,如果强制 setFps(30) 会返回错误。最好的办法就是先枚举,再选择,不要一拍脑门写死。
5. 踩坑记录与问题排查速查表
5.1 Linux下无法打开设备:权限问题
这个是我遇到过的最多的问题,没有之一。症状是程序启动后在device.open()这一步返回STATUS_ERROR,错误信息可能是DeviceOpen failed或者Access denied。原因几乎都是 udev 规则没生效,或者用户不在设备组里。
排查步骤:
lsusb | grep -i orbbec如果这条命令能看到设备,说明 USB 层面正常。再看设备节点权限:
ls -l /dev/bus/usb/001/*如果设备对应的文件权限是root root,那你普通用户访问不了。解决方法是把 udev 规则文件放好,重新插拔相机,然后执行sudo udevadm control --reload。如果还是不行,直接把用户加入plugdev组并重启会话。实在着急时,临时用sudo ./depth_viewer运行也可以,但正式部署必须把权限配好。
5.2 深度图全黑或者全是零
深度图全黑,很多初学者第一反应是“相机坏了”。实际上绝大多数情况是显示处理不对。原始深度数据是 16 位,你用 8 位图像直接显示,如果距离都在 2000mm 以上,那么在 0~255 的灰度区间里确实接近黑色。
解决办法是用convertTo(depth8u, CV_8U, 255.0 / 8000.0)或者其他归一化手段处理后再显示。另外要检查采集到的深度最大值和最小值,可以在代码里加一行:
double minVal, maxVal; cv::minMaxLoc(depth16, &minVal, &maxVal); std::cout << "深度范围: " << minVal << " ~ " << maxVal << std::endl;如果最大值只有几十毫米,说明相机确实没解算出有效深度,这时候再考虑硬件问题,比如太近、强光干扰或者镜头脏了。
5.3 编译时找不到头文件或链接失败
编译报错找不到 OpenNI.h,基本就是 Include 路径没指对。检查 CMake 里的OpenNI2_INCLUDE_DIR是否真的指向包含OpenNI.h的目录,而不是 OpenNI2 的根目录。链接失败报cannot find -lOpenNI2,在 Linux 上检查 Redist 目录下是不是真的有libOpenNI2.so文件,并用file命令确认架构:
file libOpenNI2.so输出里应该能看到x86-64或者aarch64。如果在 x64 机器上拿到 arm64 包,编译能过但运行会报cannot open shared object file或者直接段错误,这个特别容易在下载 SDK 时搞错架构。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| Linux下设备打开失败,提示Access denied | udev规则未生效或用户无权限 | 安装orbbec-usb.rules,重载udev,加入plugdev组 |
| Windows下设备管理器出现未知设备 | USB驱动未正确安装 | 到官网下载驱动手动安装 |
| 深度图全黑 | 显示时未对16位值做归一化 | 用convertTo或minMaxLoc处理后再显示 |
| 深度值几乎全是0 | 目标太近/太远、反光、遮挡 | 调整距离,避免强红外干扰,清洁镜头 |
| 同时开深度流和彩色流掉帧 | USB带宽不足 | 降低分辨率或帧率,避免使用USB HUB |
| 链接失败cannot find -lOpenNI2 | 库路径配置错误 | 检查Redist目录路径,确认库文件存在 |
| 程序启动崩溃 | SDK版本与固件不匹配 | 尝试更新或更换OpenNI2版本 |
| 设置视频模式返回错误 | 设备不支持该分辨率和帧率组合 | 先枚举getSupportedVideoModes,再设置 |
每次遇到问题,我习惯先做两件事:第一,把 OpenNI2 的日志打开。在 exe 同目录放一个OpenNI2.ini文件:
LogLevel=2 LogToConsole=1 LogToFile=1这样终端和文件里都会输出详细日志,很多初始化问题一眼就能看出来。第二,用厂商自带的示例程序验证硬件,比如 SimpleRead 或 SampleViewer。如果官方示例都跑不通,那问题基本在环境;如果官方示例正常,问题大概率在你自己代码里。
最后分享一点个人体会
OpenNI2 这套东西虽然有些年头了,但在 Astra 系列相机上依然是一条稳定可靠的技术路线,特别适合那些需要长期维护、多平台部署的视觉项目。你不需要把 SDK 里的所有接口都搞懂,把初始化、读帧、清理这三个环节吃透,再学会枚举设备支持的模式,已经能应对绝大多数需求。调 A 平台踩过的坑,到 B 平台大概率还会再踩一次,所以建议把这些经验整理进项目的 README,别低估笔记的价值。