做这个项目的时候,我手头正好有一台思岚的RPLIDAR A1,开发环境是Ubuntu 20.04 + Qt 5.15.2,目标很直接:用Qt写一个桌面端上位机,把雷达数据实时读出来,在界面上画出扫描轮廓,同时把串口原始数据也存到本地方便排查。整个过程踩了不少坑,尤其是串口数据断帧和Qt绘图效率这两块,花了大半天才理顺。这篇东西就把我完整的开发过程、代码思路和调试经验整理出来,给准备在Qt里接思岚激光雷达的朋友做个参考。
1. 项目整体设计与方案选型
1.1 为什么选Qt做上位机
当时选型其实纠结过几个方案。C++原生的Win32或MFC能做,但界面开发效率太低;Python的pyqtgraph或matplotlib做可视化很舒服,可后续要打包给现场工程用,Python环境分发和性能都差点意思;最后定在Qt上,原因有三:一是Qt的QPainter绘图引擎足够快,配合QTimer或定时器能做到30帧以上刷新;二是Qt的信号槽机制天生适合处理串口这种异步事件流;三是跨平台,调试时在Ubuntu上跑,后面要移植到Windows或者国产系统,改动成本非常低。
硬件这块选的是思岚RPLIDAR A1,它的测距半径12米,扫描频率大概5到10Hz,采用三角测距原理,精度在毫米级,做室内机器人定位和环境建图足够用了。A1通过串口或者USB虚拟串口输出数据,通信协议是固定的帧格式,用SDK读取非常方便。如果你用的是A2、A3或者S系列,原理一样,无非是通信方式、测距上限和角度分辨率不同,代码基本可以复用。
整体架构分三层:底层是串口通信与SDK数据采集,中间是环形缓冲与坐标转换,上层是QPainter绘制的2D扫描图。串口数据读取放在一个QThread线程里,避免阻塞UI线程;坐标转换直接在回调里做完,降低UI侧的计算压力。
1.2 整体架构与功能拆解
按照实际需求,我把功能拆成几个模块:
- 设备管理模块:负责初始化SDK、连接串口、启动/停止扫描,以及读取雷达型号、固件版本等基础信息。
- 数据采集模块:跑在独立线程里,循环抓取扫描数据帧,通过Qt信号把坐标数据传输给界面。
- 数据解析与坐标转换模块:把极坐标(角度、距离)变换为直角坐标,同时做数据过滤(角度异常、距离异常、信号质量差的点)。
- 可视化模块:用QPainter绘制雷达扫描轮廓,支持网格背景、动态缩放、扫描点颜色渐变。
- 数据存储模块:将原始帧记录成日志文件,方便后续回放和排查问题。
[外链图片上传失败...(source: https://images.weserv.nl/?url=https://mmbiz.qpic.cn/sz_mmbiz_png/XpCSULTtbOd2QCqLYog5EnT6vKBcHICYibVV0Zl4XcibHb46kUR9uJwGW1JzCalIrDEONTu5hia5Nvp68AJdibTrA/640?wx_fmt=png)]
这个分工做下来,最大的收益就是排查问题的时候思路非常清楚。比如界面卡顿,优先怀疑UI线程被阻塞;数据有跳变,就重点查串口丢帧和SDK回调;坐标位置不对,再看角度补偿和零点标定。模块化让每个环节都能单独测试。
2. 环境准备与SDK接入
2.1 Ubuntu 20.04下的Qt与雷达环境搭建
先说Qt环境。我用的Qt 5.15.2,安装的时候建议直接装在线安装包,勾选Qt 5.15.2的Desktop版本,编译器选gcc_64。如果网络不好或者在线安装器总是断,用镜像站点下载离线包也行。Ubuntu 20.04下还需要先把基础工具链装好:
sudo apt update sudo apt install build-essential libgl1-mesa-dev libglu1-mesa-dev freeglut3-dev sudo apt install libudev-dev libpcap-dev思岚的SDK一般不带udev规则,如果直接用普通用户运行,串口设备通常需要权限。我的做法是先把当前用户加入dialout组:
sudo usermod -a -G dialout $USER改完组之后必须注销重新登录才生效。这个坑我一开始不知道,折腾了好一会儿才发现每次打开串口都提示Permission denied,其实不是代码问题,就是权限没到位。
SDK部分,直接从思岚GitHub仓库拉下来:
git clone https://github.com/Slamtec/rplidar_sdk.git cd rplidar_sdk make编译完会生成几个Demo可执行文件,先用它的ultra_simple先测一下串口能不能正常出数据:
./ultra_simple /dev/ttyUSB0能持续滚动输出距离和角度数据,说明硬件和串口链路是通的。这一步我强烈建议先做,避免后面Qt代码写完了却发现是硬件连接的问题。
2.2 CMake工程配置与SDK链接方式
思岚SDK本身提供源码和Makefile,接入Qt工程的时候我选择用CMake直接编译SDK源码,这样最省事,不用手工搞静态库。在Qt工程里的CMakeLists.txt大致这样写:
cmake_minimum_required(VERSION 3.5) project(rplidar_qt_demo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) find_package(Qt5 REQUIRED COMPONENTS Widgets Charts SerialPort) # 思岚SDK源码目录 set(RPLIDAR_SDK_DIR ${CMAKE_CURRENT_SOURCE_DIR}/rplidar_sdk) include_directories(${RPLIDAR_SDK_DIR}/sdk/include ${RPLIDAR_SDK_DIR}/sdk/src) add_executable(${PROJECT_NAME} main.cpp mainwindow.cpp mainwindow.h radarworker.cpp radarworker.h plotwidget.cpp plotwidget.h ) target_link_libraries(${PROJECT_NAME} Qt5::Widgets Qt5::Charts Qt5::SerialPort pthread ) # 把SDK里的.c/.cpp文件直接加进来编译 file(GLOB RPLIDAR_SDK_SOURCES ${RPLIDAR_SDK_DIR}/sdk/src/*.cpp ${RPLIDAR_SDK_DIR}/sdk/src/arch/linux/*.cpp ) target_sources(${PROJECT_NAME} PRIVATE ${RPLIDAR_SDK_SOURCES})这里有个细节容易踩坑:如果SDK里同时有.c和.cpp文件,CMake默认会区分,但如果混编到同一个target,最好在文件头部自己处理一下编译标志位。我在实际工程里用了一个小封装CMake模块来区分.c和.cpp,避免出现GCC把.c当C++编译导致的警告和隐含错误。
3. 激光雷达数据读取与核心实现
3.1 串口通信与SDK初始化流程
串口连接是第一步。RPLIDAR的串口波特率A1一般是115200,但SDK里面已经封装好了,不需要你手动去配置。如果你直接用QSerialPort裸读串口数据,就需要注意波特率、数据位、停止位这些参数配置,而且解析协议会比较麻烦。我推荐直接用SDK提供的RPlidarDriver接口。
初始化大致分四步:
- 创建驱动实例。
- 连接串口。
- 获取设备信息和健康状态。
- 启动扫描。
核心代码片段如下:
#include "rplidar.h" using namespace sl; // 创建并连接雷达 RPlidarDriver* lidarDriver = RPlidarDriver::CreateDriver(DRIVER_TYPE_SERIALPORT); if (IS_FAIL(lidarDriver->connect("/dev/ttyUSB0", 115200))) { qDebug() << "连接失败"; } // 检查健康状态 sl_result opResult; rplidar_response_device_info_t devInfo; lidarDriver->getDeviceInfo(devInfo, opResult); qDebug() << "型号:" << devInfo.model; qDebug() << "固件版本:" << devInfo.firmware_version; // 启动扫描 lidarDriver->startMotor(); lidarDriver->startScan();这个startMotor之前我漏过一次,结果雷达电机不转,数据永远是0,后来才发现是靠电机驱动的旋转镜面,必须让电机转起来才能扫。SDK里面startMotor实际上是控制电机PWM,老版本SDK还有需要调用setMotorPWM的情况,具体看你SDK版本。
3.2 扫描数据回调与极坐标转直角坐标
RPLIDAR的扫描结果通过grabScanData函数阻塞获取。在实际开发里,我专门开了一个worker线程循环调用这个函数,然后把回调结果通过信号发到主线程。
核心循环大概长这样:
void RadarWorker::run() { while (m_running) { sl_lidar_response_measurement_node_hq_t nodes[8192]; size_t nodeCount = sizeof(nodes) / sizeof(nodes[0]); sl_result opResult = m_driver->grabScanDataHq(nodes, nodeCount); if (IS_OK(opResult)) { m_driver->ascendScanData(nodes, nodeCount); QVector<QPointF> points; // 极坐标 -> 直角坐标 for (size_t i = 0; i < nodeCount; ++i) { if (nodes[i].dist_q2 == 0) continue; float angle = nodes[i].angle_z_q14 * 90.f / 16384.f; // 角度 float dist = nodes[i].dist_q2 / 4.0f / 1000.0f; // 距离转米 if (dist > 12.0f || dist < 0.05f) continue; // 测距范围限制 float rad = qDegreesToRadians(angle); points.append(QPointF(dist * qCos(rad), dist * qSin(rad))); } emit scanDataReady(points, nodeCount); } QThread::msleep(10); } }这里有两个细节要特别说明。第一个是angle_z_q14和dist_q2这两个字段是SDK的定点数编码,不能直接拿来用。angle要乘以90再除以16384,dist要除以4得到毫米,再除以1000变成米。这个转换公式在SDK文档里有,但新手很容易忽略,导致画出来的图要么是个线段要么是随机噪点。第二个是ascendScanData的作用,它会把无序的点按角度从小到大排序,这就让后续的点云数据在绘制时更连续,不做这步的话画出来的图是乱序的,轮廓线会来回穿插。
3.3 线程模型与信号槽衔接设计
激光雷达的数据频率不低,A1每秒钟可以采集2000到4000个点,如果把解析和坐标转换都放在UI线程里,界面刷新一定会卡成幻灯片。我的方案是:
- RadarWorker线程:只做SDK数据抓取、极坐标转换、无效点过滤。
- 主线程:只负责接收QVector 信号,触发QPainter绘制。
线程之间通过Qt信号槽传递数据。这里有个性能细节:如果直接传QVector,内部会触发一次深拷贝。数据量小的时候无所谓,但点云数据每帧几千个点,高频刷新下就会产生明显开销。我实际项目里使用了std::shared_ptr<QVector<QPointF>>来传,信号槽传递的时候只拷贝智能指针,几乎零成本。
// 信号定义 signals: void scanDataReady(std::shared_ptr<QVector<QPointF>> points); // 发送端 auto sharedPoints = std::make_shared<QVector<QPointF>>(points); emit scanDataReady(sharedPoints); // 接收槽函数 void MainWindow::onScanDataReady(std::shared_ptr<QVector<QPointF>> points) { m_plotWidget->updatePoints(points); }4. Qt界面绘制与实时可视化
4.1 QPainter绘制2D扫描图的思路
绘制雷达扫描图,本质上就是把点云数据映射到屏幕上。我用的是继承QWidget自定义绘图的方式,重写paintEvent。核心步骤:
- 先清空画布,画一个深色背景。
- 绘制网格线,模拟雷达的极坐标网。
- 计算坐标变换矩阵,把真实世界的米制坐标转换为屏幕像素坐标。
- 逐点绘制扫描点,距离越近颜色越偏暖,距离越远颜色越偏冷。
坐标变换是这里面比较核心的部分。我用了Qt的QTransform来实现平移和缩放:
void PlotWidget::paintEvent(QPaintEvent*) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 背景 painter.fillRect(rect(), QColor(20, 24, 30)); // 平移原点到窗口中心 painter.translate(width() / 2, height() / 2); // 缩放(假设界面显示范围是±10米) double scale = qMin(width(), height()) / 20.0; painter.scale(scale, -scale); // y轴要翻转 // 绘制网格 drawGrid(painter); // 绘制扫描点 painter.setPen(Qt::NoPen); painter.setBrush(QColor(0, 220, 130, 180)); if (m_points) { for (const QPointF& pt : *m_points) { painter.drawEllipse(pt, 0.02, 0.02); } } }注意scale里我把y轴做了翻转,因为雷达坐标系的y正方向是朝上的,而QPainter的默认坐标系y轴朝下。如果不翻转,整个地图会上下颠倒。我第一次没做这步,雷达转一圈画出来是个镜像图形,一开始还以为是数据问题,后来拿一个物体在跟前左右移动对比才发现是坐标系方向的反了。
4.2 刷新频率与性能优化技巧
实时刷新是雷达可视化的核心体验,A1一个周期大概100到200毫秒,也就是每秒5到10帧的扫描数据。如果每个点都用drawEllipse画圆形,性能会非常差,尤其是点云达到2000点时,界面明显掉帧。
优化思路有三个:
- 小圆点改成小矩形,用drawRect代替drawEllipse,渲染开销小很多。
- 开启最小化抗锯齿,或者关闭抗锯齿。雷达点云本身是离散点,锯齿感不明显,关掉Antialiasing可以省出大量CPU。
- 用QPainterPath一次性把整帧所有点批量路径化,然后一次strokePath或fillPath。QPainterPath大量减少draw调用次数。
优化后的绘制代码:
QPainterPath path; for (const QPointF& pt : *m_points) { path.addRect(QRectF(pt.x() - 0.03, pt.y() - 0.03, 0.06, 0.06)); } painter.fillPath(path, QColor(0, 220, 130, 180));这么做之后,连续跑半小时雷达,界面依旧稳定在30帧以上,CPU占用率也明显下降。如果项目里还需要显示历史轨迹、绘制地图轮廓,也可以把点时序做成一个环形缓冲,每次只绘制增量点,再叠加整幅背景图,这是后话了。
4.3 历史轨迹显示与清屏功能
实际场景里,单帧显示只能看到当前扫描截面的瞬时轮廓。如果想要观察机器人移动过程中的环境变化,或者验证雷达是否稳定,加一个“历史轨迹”模式就很实用了。我实现了一个简单版本:
- 每次onScanDataReady的时候,把当前帧的所有点追加到一个历史点容器里。
- 绘制时,历史点用半透明低亮度颜色,当前帧点用高亮颜色,这样能看出扫描范围的变化过程。
- 加一个“清空轨迹”按钮,可以随时复位历史数据。
这个功能在调试雷达角度偏移的时候特别好用。你把雷达放在原地转一圈,历史轨迹如果是一个标准的360度圆环,说明数据正常;如果轨迹存在缺口或者圆环发生错位,基本可以断定是机械遮挡、串口丢帧或者电机转速不稳定。
5. 数据记录、回放与常见问题排查
5.1 原始数据存储与回放调试
日常开发和现场问题定位时,日志回放的重要性不亚于实时显示。实时跑的时候肉眼看到图形闪烁或者跳变,没有日志根本没法复现。我的做法是把每次grabScanData获取到的节点数据直接以二进制或CSV格式落盘:
timestamp_ms, angle_q14, dist_q2 1752492300000, 4123, 5620 1752492300000, 4124, 5618 1752492300000, 4125, 5601CSV的好处是用Excel或者Python都可以直接分析。如果在现场发现某一帧的角度跳变特别大,把CSV拉出来画个折线图就能看出来。后来我还用Qt写了一个简单的离线回放按钮,读取CSV后模拟发送scanDataReady信号,这样界面表现就和实时完全一致,排查问题效率提高了不少。
5.2 常见问题排查速查表
开发过程中我整理了一份踩坑清单,放在这里当速查表用:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 串口打开失败 | 串口号错误或没有权限 | 检查设备枚举,加入dialout组后重新登录 |
| 电机不转 | 未调用startMotor或PWM异常 | 确保驱动实例启动扫描前已启动电机 |
| 数据全为0 | 雷达被遮挡或距离异常 | 检查测距范围过滤条件,确认雷达前方无遮挡 |
| 图形上下颠倒 | 坐标系未翻转 | QPainter的y轴scale设为负值 |
| 画面闪烁卡顿 | 每帧直接用drawEllipse | 改用QPainterPath批量填充,关闭抗锯齿 |
| 扫描轨迹有缺口 | 机械遮挡、电机转速不稳或串口丢帧 | 检查旋转机构,增大串口缓冲区,确认USB供电充足 |
| 角度跳跃乱序 | 未调用ascendScanData排序 | 在grabScanData后调用ascendScanData |
| 坐标转换错误 | 定点数解析公式不对 | angle乘90除以16384,dist除以4再除以1000 |
5.3 性能瓶颈与后续优化记录
雷达数据本身频率不算高,真正麻烦的是在嵌入式或者工控机上跑Qt界面。A1如果开启最高扫描频率,一帧可能3000多个点,加上绘图、存储和UI刷新,低功耗处理器可能压力很大。后面如果遇到性能瓶颈,我计划做两件事:一是把点云坐标转换移动到GPU侧,用OpenGL的VBO绘制点云,或者Qt的QSG几何节点,渲染速度会快一个数量级。二是针对雷达数据做成动态降采样,当点云密度过高时,只保留一部分点比如每两个点采样一个,保证刷新率优先。
目前这个Qt思岚激光雷达演示程序已经可以在普通笔记本电脑上跑到40帧左右,A1扫描6到10Hz的环境完全没问题。整个项目跑通之后,我又扩展了几个小功能:把雷达点云数据和IMU数据简单融合,做实时角度矫正;用QChart生成了实时距离柱状图;还尝试了一下把扫描输出转成ROS的LaserScan消息格式,以后要接机器人系统可以直接复用。
如果你们也在用思岚雷达做上位机或机器人项目,建议第一步就先装好SDK,跑通命令行Demo的串口通信,再考虑GUI封装。Qt那边的核心就是线程分离和QPainter优化,这两个点抓好了,整个项目基本就稳了一大半。下一次迭代我打算把OpenGL点云显示和动态目标识别加进去,到时候再单独写一篇分享。