如果你手头正好有一台SICK LMS111,别急着把它当高级玩具或者直接扔给ROS跑现成驱动。这阵子我在做一个仓库搬运平台的改造,把一台吃灰多年的LMS111重新翻出来,从RS485差分线一路剥到三维点云,整个过程踩了不少坑,也把协议彻底搞明白了。这篇东西就是一次完整复盘,从底层原始帧怎么拆、距离角度怎么换算,到加一个转台把2D扫描变成3D点云,再到后续接ROS2配合Cartographer建图时那些"飘"的问题到底出在哪,一次说清楚。
不管你只想要一份能跑的解析代码,还是想弄明白LMS111为什么同一个点一会儿近一会儿远,又或者只是听过"激光雷达SLAM"几个词想从零开始,这篇都能给你一个完整路径。我尽量不端着讲术语,该上代码上代码,该晒坑晒坑。
1. 摸清LMS111的脾气:单线雷达到底能干什么
1.1 这台被炒到天价的老将,参数其实很朴素
SICK LMS111属于LMS1xx系列里的经典款,放在今天看参数并不惊艳:单线扫描、270度范围、20米标称距离,角度分辨率最高0.25度,扫描频率一般用25Hz或50Hz。可它在工业自动化、AGV导航、港口防撞里地位一直很稳,因为皮实、可靠、IP67防护,红外905nm激光,对环境光的抗性比很多消费级雷达强一截。
我这里从SOPAS里读出来的配置是:扫描频率25Hz、角分辨率0.25度,这样一帧就是1081个点。如果把角分辨率放宽到0.5度,一帧541个点,扫描频率能上到50Hz。说白了就是一个固定平面的截面扫描,雷达转动的是内部棱镜,每条光束发出去测到距离,就得到一个以雷达为原点的极坐标点。这个"只扫一个平面"的特性,是理解后面所有东西的前提。
你可能会问,单线雷达没有俯仰信息,怎么做三维点云?答案是加外部自由度。雷达本身扫X-Y平面,我们给它加一个绕Z轴转动的云台,或者让它做俯仰摆动,每一帧2D扫描线就对应三维空间里的一个截面,积累起来就是点云。思路不复杂,难的是把帧数据和云台角度对上时间。
1.2 从2D到3D的技术路线与工具链选择
把LMS111改造成三维扫描,主流做法有三种:第一种是水平扫描的雷达装在俯仰摆动的云台上,像"点头"一样一层层扫;第二种是雷达固定抬头或者低头,让转台带着绕竖直轴转,适合大范围环境重建;第三种是多台雷达拼角度。我这次用的是第二种,因为仓库里需要360度环视,俯仰方向只需要把安装倾角固定好就够。
工具链上我全程用Python,串口读取用pyserial,数值计算用numpy,2D可视化用matplotlib,3D点云用Open3D。后续要接ROS2时,再做一层ROS2驱动节点,把解析结果发布成sensor_msgs/LaserScan。前面数据解析拿到的是干净的帧数据,后面接SLAM还是做目标检测都顺理成章。
2. LMS111通信协议与原始帧解剖
2.1 RS485差分信号和物理接线
LMS111在硬件上保留了RS485接口,工业现场很喜欢这种差分信号方案,两根线A和B,靠电压差传输,抗共模干扰能力强,布线距离可以到百米以上。我这次就是买了一个USB转RS485的调试棒,A接A、B接B,另外把GND也连上,别小看这根地线,不接的时候偶尔会出现整帧丢字节的情况。
RS485是半双工,同一时刻只能收或者发,所以串口参数必须和传感器侧对齐。我习惯在SOPAS软件里先把波特率固定住,测试时设为115200,8位数据位,无校验,1位停止位。如果你收到的数据全是乱码,先别怀疑协议,先用串口助手看一眼十六进制,只要是"02 73 52 41..."这样的固定头,就说明物理链路通了。
接线的坑主要在插针定义上,不同批次的LMS111接口定义可能略有差异,动手前先对着铭牌查一下针脚定义,别盲插。有一次我就是因为手里的线序定义是旧版本的,结果A和B反了,串口上永远都是错码,折腾了半天才发现是线序问题。
2.2 一个完整数据帧的内部结构
LMS111数据帧的主体是ASCII文本加二进制控制字符混排的格式,帧头是STX,十六进制0x02,紧接着是ASCII字符"sRA LMDscandata"作为类型标识,然后是空格分隔的十进制数字段,帧尾是ETX(0x03),最后跟一个字节的校验和。
为了直观,我把一帧的十六进制开头部分列出来给你看:
02 73 52 41 20 4C 4D 44 73 63 61 6E 64 61 74 61 20 31 20 31 20 30 20 30 20 35 ...解析成ASCII就是:
sRA LMDscandata 1 1 0 0 5 ..."sRA"是SICK协议里扫描数据响应的含义,"LMDscandata"则是数据块标识,后面跟着的字段就是真正的"内脏"。版本号、设备号、序列号、设备状态、电报计数、扫描计数、上电时间毫秒数、传输状态、扫描频率、测量频率……这些是头部信息。我在实际解析时不太死记每个字段的绝对位置,因为不同固件版本偶尔会差一个字段,但核心原则一样:先按空格切分,再根据"通道数量"字段动态定位通道数据区。
2.3 距离、角度和反射率到底藏在哪个字段
LMS111典型输出是两个通道,第一个通道是距离,第二个通道是反射强度(RSSI)。每个通道在帧里都有几个描述参数:通道类型、编码数量、scale factor、scale offset、起始角度、角步长、数据点数量,最后才是真正的数据序列。
距离值的换算公式非常关键:
实际距离(mm) = 原始整数值 / scale_factor - scale_offset注意scale offset前面是减号还是加号,不同协议版本文档写法不一样,我在自己设备上实测是减法,你在SOPAS里和看到的距离对比一下就知道符号对不对。角度值则是:
实际角度(度) = 起始角(1/10000度) / 10000 + 索引编号 * 角步长(1/10000度) / 10000LMS111在0.25度分辨率下,起始角常见值是-450000,也就是-45度,角步长2500,也就是0.25度,点数1081。从-45度开始,一直扫到225度,正好覆盖270度范围。有了距离和角度,每个二维点的极坐标就齐了。
3. 原始帧解析实战:手写Python解析器
3.1 串口读取与组帧同步
串口读数据时不会正好一次收到一帧,所以要自己组帧。我的做法是逐字节读,遇到STX(0x02)开始缓存,一直读到ETX(0x03)为止,再把ETX后面的校验字节一起收进来。这里有个容易踩的坑:串口缓冲区里可能混着半帧或者脏数据,所以必须用STX重新同步,不能按固定长度切。
import serial ser = serial.Serial( port='/dev/ttyUSB0', baudrate=115200, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=1.0 ) def read_lms111_frame(ser): while True: head = ser.read(1) if head != b'\x02': continue buf = bytearray(b'\x02') while True: c = ser.read(1) if not c: break buf.append(c[0]) if c == b'\x03': # 再读一个校验字节 chk = ser.read(1) if chk: buf.append(chk[0]) return bytes(buf)注意,这个函数里用STX同步的方式是很实用的习惯,哪怕前面有垃圾数据,只要等到了0x02就能重新对齐。工程上我还会加一个超时保护,防止长时间没有完整帧时死循环。
3.2 解析帧数据的核心代码
拿到完整帧后,去掉首尾的STX、ETX和校验字节,剩下的ASCII部分按空格切分。我的解析思路是动态定位通道,而不是写死所有索引。第一步先确认帧头字段,第二步找到"通道数量"字段,第三步循环解析每个通道。
def parse_lms111_frame(frame): # frame 是不含STX/ETX的字节序列 tokens = frame.decode('ascii', errors='ignore').split(' ') # 前三个token: s, RA, LMDscandata assert tokens[0] == 's' and tokens[1] == 'RA' and tokens[2] == 'LMDscandata' idx = 3 version = int(tokens[idx]); idx += 1 # 版本号 device = int(tokens[idx]); idx += 1 # 设备号 serial_no = int(tokens[idx]); idx += 1 # 序列号 status = int(tokens[idx]); idx += 1 # 设备状态 telegram_count = int(tokens[idx]); idx += 1 scan_count = int(tokens[idx]); idx += 1 time_ms = int(tokens[idx]); idx += 1 transmission = int(tokens[idx]); idx += 1 scan_freq_raw = int(tokens[idx]); idx += 1 meas_freq_raw = int(tokens[idx]); idx += 1 num_channels = int(tokens[idx]); idx += 1 channels = [] for _ in range(num_channels): ch_type = int(tokens[idx]); idx += 1 # 1=距离, 0=RSSI等 enc_num = int(tokens[idx]); idx += 1 # 编码数量 scale_factor = int(tokens[idx]); idx += 1 scale_offset = int(tokens[idx]); idx += 1 start_angle_raw = int(tokens[idx]); idx += 1 step_angle_raw = int(tokens[idx]); idx += 1 point_count = int(tokens[idx]); idx += 1 values = [] for _ in range(point_count): values.append(int(tokens[idx])) idx += 1 channels.append({ 'type': ch_type, 'scale_factor': scale_factor, 'scale_offset': scale_offset, 'start_angle_raw': start_angle_raw, 'step_angle_raw': step_angle_raw, 'values': values }) return { 'version': version, 'device': device, 'serial_no': serial_no, 'scan_count': scan_count, 'time_ms': time_ms, 'channels': channels }这段代码有一个地方需要你实际对一下:不同固件的头部字段数量可能差一个,如果你解析出来的通道数量不对,多半是头部某个字段切错了。我的校验方法很简单:看距离通道的点数是不是1081(0.25度分辨率)或者541(0.5度分辨率),如果是,说明索引对了。
3.3 把极坐标转成二维像素看效果
解析出距离和角度后,先用matplotlib画一下二维扫描线,能立刻确认数据链路是不是通的。这里我把距离通道换算成毫米,再按角度展开到X-Y平面,过滤掉无效点。LMS111对于测不到回波的场景会输出特定的大值或小值,一般可以选择设定阈值过滤,比如距离大于20000mm的直接丢弃。
import numpy as np import matplotlib.pyplot as plt def scan_channel_to_xy(channel): raw = np.array(channel['values'], dtype=np.float64) dist_mm = raw / channel['scale_factor'] - channel['scale_offset'] start_deg = channel['start_angle_raw'] / 10000.0 step_deg = channel['step_angle_raw'] / 10000.0 n = len(raw) angles_deg = start_deg + np.arange(n) * step_deg angles = np.deg2rad(angles_deg) x = dist_mm * np.cos(angles) y = dist_mm * np.sin(angles) return x, y frame = read_lms111_frame(ser) parsed = parse_lms111_frame(frame[1:-2]) # 去掉STX/ETX/checksum dist_ch = parsed['channels'][0] x, y = scan_channel_to_xy(dist_ch) plt.figure(figsize=(8, 8)) plt.scatter(x, y, s=1) plt.axis('equal') plt.show()跑到这一步,你基本已经拿到LMS111吐出来的2D截面图了。第一次看到数据正常出图的时候还挺有成就感的,但真正的重头戏还在后面——让这条扫描线在空间里"动"起来,攒成三维点云。
4. 从2D扫描线到三维点云:坐标变换与转台方案
4.1 三个坐标系和外参到底在说什么
谈到三维点云,绕不开坐标系。我这边涉及三个坐标系:雷达自身坐标系、云台/转台坐标系、世界坐标系。雷达自身坐标系的原点在雷达光学中心,X-Y平面就是扫描平面。转台坐标系的原点在转台旋转轴上,Z轴是旋转轴。世界坐标系是最终点云所在的参考系,一般和转台底座固定。
外参的本质就是雷达坐标系到转台坐标系的平移量和旋转量。比如雷达装在转台上面,安装高度是0.5米,那么平移向量就是[0, 0, 0.5]。如果雷达安装时还有俯仰角或者滚动角,就需要一个旋转矩阵把雷达扫描平面扭正。很多点云歪掉的情况,就是外参里那几度没标对。
一个很常见的直觉类比:你拿着一把激光笔站在旋转椅子上,激光笔是雷达,椅子是转台,你的身高和坐姿就是外参。椅子转是角度同步,你歪着坐,扫出来的墙就是歪的。
4.2 角度同步才是三维重建的灵魂
单帧2D数据只能画一条弧线,要变成3D,必须知道这条弧线在转台转到哪个角度时采的。这里有两个层次的做法。
最省事的是"软同步":转台匀速转,通过USB/串口把每一帧的编码器角度和时间戳同时读回来,再用时间戳给每帧打上角度标签。如果转台速度很稳,线性插值就行;速度不稳就要用编码器实时角度。
更好的是"硬同步":转台每转到一个固定角度步进,通过IO触发LMS111采集一帧。这样每帧和角度严格对应,不依赖时间戳精度。我用的是中间方案,转台控制器每个周期输出当前角度,我用单片机把角度和时间打包成串口数据,和LMS111的扫描帧放在同一台工控机上,最后按scan_count和时间戳对齐。
这里必须多说一句:如果你发现点云在墙面上出现扭曲或重影,十有八九不是雷达坏了,而是角度同步没做好。转台转速抖动、串口延迟、时间戳没有统一时钟,都会让同一面墙被拧成麻花。
4.3 坐标变换代码和Open3D可视化
假设现在我的雷达水平安装,绕Z轴旋转的转台在某个时刻给出了偏航角yaw_deg。对当前这一帧内的任意一点,先算它在雷达坐标系下的三维坐标,再绕Z轴旋转yaw。
def scan_line_to_3d_points(channel, yaw_deg, z_offset=0.0): raw = np.array(channel['values'], dtype=np.float64) dist_mm = raw / channel['scale_factor'] - channel['scale_offset'] dist_m = dist_mm / 1000.0 start_deg = channel['start_angle_raw'] / 10000.0 step_deg = channel['step_angle_raw'] / 10000.0 n = len(raw) angles_deg = start_deg + np.arange(n) * step_deg theta = np.deg2rad(angles_deg) # 雷达扫描平面内的坐标(Z=0) x_l = dist_m * np.cos(theta) y_l = dist_m * np.sin(theta) z_l = np.zeros_like(x_l) # 绕Z轴旋转yaw_deg alpha = np.deg2rad(yaw_deg) cos_a = np.cos(alpha) sin_a = np.sin(alpha) x_w = x_l * cos_a - y_l * sin_a y_w = x_l * sin_a + y_l * cos_a z_w = z_l + z_offset # 过滤无效距离 valid = dist_m > 0.1 return np.stack([x_w[valid], y_w[valid], z_w[valid]], axis=1)把多帧累积起来,用Open3D显示:
import open3d as o3d all_points = [] # points_by_scan假设是每帧解析出的点集合 for i, p in enumerate(points_by_scan): all_points.append(p) cloud = np.concatenate(all_points, axis=0) pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(cloud) o3d.io.write_point_cloud("warehouse_scene.pcd", pcd) o3d.visualization.draw_geometries([pcd])如果一切正常,你会看到原本一条条的扫描弧线在空间里铺开,墙面、货架、柱子逐渐成型。我第一次拿这个流程扫了一个20米长的仓库通道,效果比预想中好很多,尤其是柱子和门洞的位置,轮廓非常清楚。
4.4 外参标定的土办法
外参标定坦白讲是个脏活。最靠谱的办法是用一个已知尺寸的立方体标定架,或者干脆用一面足够平的墙。先把转台归零,让雷达正面朝向墙面扫一帧,记录墙面点在雷达坐标系下的法向量;再把雷达装到转台上的俯仰角误差,通过误差角和墙角特征反算出来。
我自己的土办法是:先用卷尺量出雷达光学中心到转台旋转轴的水平距离和高度差,作为初始外参;然后扫一圈,看三个不同距离的柱子在点云里是什么形状。如果柱子拉得很扁或者呈圆弧状,说明外参的旋转部分有偏差;如果柱子位置整体偏了,说明平移部分有误。再用手动调整加细化微调,一般半小时能收敛。你也可以扫完直接和已知尺度的CAD图对齐,用ICP算法自动迭代,但前提是初始值别差太离谱。
5. 工程化避坑与常见问题速查
5.1 解析阶段:校验失败、丢帧、乱码
我在解析阶段遇到的问题可以整理成一张速查表:
现象 可能原因 处理方式 串口全乱码 波特率不对 / A、B线接反 / GND没接 核对SOPAS配置,交换A/B,补接GND 一帧很长但解析出来点数不对 头部字段索引错误 / 固件版本有差异 打印完整字段列表,人工数一遍关键索引 偶尔跳帧或半帧 串口缓冲区截断 / USB转485质量差 改用逐字节组帧,加STX同步 校验和一直失败 RS485半双工方向切换冲突 / 采集程序占用串口 确保只有单一进程读串口 距离值整体偏大或偏小 scale_offset符号错误 和SOPAS读数对比,修正换算公式这里特别说一下校验和,LMS111帧末尾那个校验字节,我自己实测下来就是对前面所有字节累加取低8位。有些文档描述为"STX checksum",实现时要注意有些固件会把STX排除在外,所以我在代码里会同时算含STX和不含STX两个累加结果,哪个对用哪个。这种偏移问题在工业协议里太常见了,别迷信文档,以实际抓包为准。
5.2 建图飘、点云飘的常见套路
"激光雷达建图飘"是高频问题,我在这个项目上也碰到了。首先要排查的永远是外参和时间同步。雷达相对于转台或者车体差半度,在10米外就是10厘米的偏差,这在建图里足够让墙变成双层。时间同步没做好的话,转台已经转走了,雷达还在发上一帧的数据,点云会拖着一条"尾巴"。
再往下排就是机械振动。LMS111本身测量很稳,但装在转台上,转台每个步进动作带来的抖动都会反映在点云里。解决方法是调整转台的加减速曲线,扫描时尽量匀速,不要在急加速阶段采数。我后来把转台每步的停留时间拉长,点云质量明显提升。
如果做SLAM的时候还飘,那就是里程计和雷达融合的问题。Cartographer里常见的几个调整方向:
1. use_online_correlative_scan_matching: true 2. 适当减小 submap 的 size,降低累积漂移 3. 如果环境特征少,打开或者调高 loop closure 相关参数 4. 加IMU或者轮式里程计约束,别只靠雷达有一点心得是:Cartographer不是参数越宽松越好,约束太强会让建图对初始外参极度敏感,外参稍微偏一点就整个地图扭曲;约束太弱又会在长走廊里飘。我最后是把外参标到误差小于0.5度,然后才去调Cartographer参数,效果立刻不一样。
5.3 反射率数据也是金矿
很多人只盯着距离通道,忽略了LMS111自带的反射强度(RSSI)。反射强度通道在帧里通常是第二通道,解析方式跟距离通道一样。这个数据特别适合做目标检测。比如地面上的反光条、货架上的标签、路沿的白线,在距离图像里可能看不出明显特征,但强度值会有一个明显跳变。
我做目标检测时最简单的办法就是对RSSI做阈值分割,先找出高反射区域,再用聚类把一个个反光条或者标靶分出来。配合距离信息,可以计算出目标在笛卡尔系下的精确位置。这个方法在反光板导航的AGV项目里很常用,比纯几何特征鲁棒得多。
5.4 后续扩展:ROS2驱动与Cartographer建图
这部分算是顺水推舟。有了前面的解析代码,封装ROS2节点很直接,把雷达数据发布成sensor_msgs/LaserScan,把转台角度和里程计信息整合进tf树,Cartographer就能直接用。
写驱动时要注意LaserScan的angle_min、angle_increment和range_max必须和帧里读出来的起始角、角步长、量程一致,否则Cartographer会在前端就把数据理解错。另外scan_count和时间戳要处理好,我这边直接把LMS111的扫描计数填到header.seq里,用上电时间毫秒数填stamp,统一在节点内部转成ROS时间,后面做时间同步才不手忙脚乱。
最后再说两句
做这个项目最大的感受是:LMS111这类工业雷达的协议说复杂也复杂,说简单也简单,难点全在细节。校验算法、字节顺序、字段偏移、scale符号,任何一个地方不对,数据就是天书。但只要把原始帧彻底拆明白了,后面无论是转台三维扫描、ROS2建图还是目标检测,都会顺畅很多。
我个人推荐的做法是,拿到雷达先别急着跑现成库,亲手写一遍解析器,哪怕只是打印出"帧号、点数、第一个点的距离"这种最原始的信息,也比直接拿黑盒驱动让你心里有底。等你真的从一串十六进制里还原出一张能看的点云图,那种掌控感,是别人和你说多少遍"这个库挺好用"都换不来的。
最后再分享一个小技巧:验证解析器对不对,可以拿一个反光板或者白纸板放在已知距离处,对比LMS111读数和卷尺量的值,距离对了再看角度。这个看似蠢的办法,帮我避开了至少三次自以为协议没问题、实际换算方向的坑。