草庐IT

【学习总结】激光雷达与相机外参标定:原理与代码1

larry_dongy 2023-08-14 原文

2023年2月重要补充

这个代码我个人觉得不好用且坑太多,所以后来换了一个。推荐大家用新的代码。
详见更新的一篇博客总结:【学习总结】激光雷达与相机外参标定:代码(cam_lidar_calibration)


这一周多学习并调试了激光雷达和相机外参标定的代码,踩了一堆坑,特此记录。

0. 参考资料:

代码来源:https://github.com/ankitdhall/lidar_camera_calibration
参考论文:LiDAR-Camera Calibration using 3D-3D Point correspondences
修改后的代码:https://github.com/LarryDong/lidar_camera_calibration

之前找了几个开源的标定代码,在github上看就这个代码具有最多的star,觉得比较靠谱。结果这个代码写的真的一言难尽:逻辑混乱,需要注意的细节极其多,以及存在不少坑。这一个多星期,就是踩坑和调试。今天终于能够正常运行出一个正确(但并不准确)的结果,在此记录。

代码混乱的原因:

  1. 大量变量定义未使用,以及定义与实际使用的距离太远,让人误解;
  2. 需要修改多个参数文件,参数管理混乱;参数由不同文件读取,同时一些临时变量也存储到文件中再在不同函数中读取调用,以及全局变量的使用;
  3. 官方使用文档一些细节没有强调。

1. 基本原理

计算雷达和相机的外参,基本原理较为简单:只需要找雷达系下的点与相机系下对应点的坐标关系,然后计算外参即可。之前一篇博客总结了几种常用的方法:【学习记录】激光雷达与相机标定

问题核心是,如何确定对应关系,即correspondence?

所采用的代码的基本原理:用一块矩形纸板,用激光雷达检测纸板的边界线,之后确定纸板的角点,获取纸板角点在lidar系下的坐标;而相机本身角点无法提供深度信息,但我们可以通过aruco提供带深度的marker的角点,再计算纸板四个角的坐标。

2. 代码总览


首先需要按照原始代码链接中的wiki进行安装,之后需要修改一些参数配置文件。如果运气好,搞清楚怎么用,或许就能一次成果。但许多细节在原始的代码readme中没有讲清楚,因此本博客详细整理。

整个代码的逻辑如下:

首先这个代码的核心是 lidar_camera_calibration下的find_transform.launch 启动的标定程序。这个程序接收数据包括:

  • 激光雷达的原始扫描数据
  • aruco marker 在 相机系下的位姿
  • 相应参数配置文件

其中激光雷达的数据有激光雷达启动节点直接提供,aruco marker的消息由aruco_mapping节点提供,相应参数配置文件为 “lidar_camera_calibration/conf/config_file.txt” 提供。

核心代码在收到上述数据后,对雷达数据进行纸板的边界线和角点检测,最后与aruco_mapping提供的位姿进行迭代求解。下面按先后顺序,介绍程序与节点。

3. 代码详细解释

3.1 数据流

首先从数据流的角度看。

图像数据流:相机驱动->aruco_mapping->”lidar_camera_calibration/conf/transform.txt"文件->find_transform节点。其中相机驱动传输完整图像给aruco_mappingaruco_mapping从其配置文件 “aruco_mapping/data/xxx.ini"文件中获取相机的内参(包括分辨率、K、畸变参数、以及投影参数等),还从"aruco_mapping/launch/aruco_mapping.launch"文件中获取marker的参数。最后aruco_mapping保存位姿数据在"lidar_camera_calibration/conf/transform.txt"文件,再由find_transform节点读取,再根据从"lidar_camera_calibration/config_file.txt"中获取的marker和纸板(以下记作"board”)的参数
用于纸板的四角在相机下参数计算。

激光雷达数据流:激光雷达驱动->find_transform,相对简单。激光雷达将原始点云数据直接发给find_transform节点,节点根据扫描情况以及"lidar_camera_calibration/conf/config_file.txt"中的配置,将点云中的边界点投影到camera的图像下,用于可视化与人工标注。

另外还有一些中间数据,例如find_transform节点将激光雷达检测出的board角点、与计算出的角点在camera系下的3D坐标,临时存储在"lidar_camera_calibration/conf/points.txt"中,用于后续的读取。

3.2 aruco_mapping节点

上述数据流已说明,aruco_mapping节点负责将原始图像获取后输出图像中marker在相机系下的坐标。它所接受的图像topic名称、marker的尺寸在aruco_mapping.launch中给出,同时还需要相机的参数,相机的参数需要提前标定并按照指定格式修改"/data/xxx.ini"配置文件。
输出6DoF位姿到"lidar_camera_calibration/conf/transform.txt"文件。这部分代码还是比较完善的,详细可参考aruco_mapping官方文档

要点:需修改接收topic名称、marker参数文件、相机参数.ini文件

3.3 激光雷达节点

激光雷达节点较为简单,即激光雷达的ros下驱动,启动后发布激光雷达数据即可。后由find_transform接收。

3.4 find_transform节点(核心)

这个节点是算法的核心,按照先后顺序以此展开。

3.4.1 消息接收与回调函数

从main函数入手,首届先接收了上述节点发送的topic。其中有一处选择,是采用"callback"还是"callback_noCam",二者的区别是:相机的内参是从topic接收,还是从文件读取。本质上是一样的,我们采用从文件读取,这样就不需要aruco_mapping节点或相机驱动节点再发送topic数据。读取的文件还是上面说的"conf/config_file.txt"。

这里在回调时用了ROS下message_filters的消息同步机制,保证camera的数据和lidar的数据是同步的。但由于场景可以认为静止,这部同步起始没有太大意义。

3.4.2 点云数据接收

进入回调函数,第一件事就是接收lidar驱动节点发出的数据。这里我遇到了第一个坑:【fromRosMsg()报错 Failed to find match for field “intensity”】。出现这个原因是因为我采用的镭神激光雷达的intensity字段定义的是奇葩的uint类型,而RosMsg下的intensity字段是float,因此无法直接转化。为此需要重新定义自定义点数据类型与拷贝函数。详见上述的学习总结。(其实intensity并没有用)

点云数据接收后,需要转成统一的点云格式,作者采用的是自定义的PointXYZRID,其中有用的字段是"I"表示intensity和"R"表示ring,即第几条线扫描到的数据。一般雷达都提供了扫描的线束信息,在雷达转化时加上即可。如果并没有提供线束信息,可以通过类似A-LOAM源码中的计算俯仰角确定线束。

紧接着遇到了第二个坑:无效数据点即NaN的问题。由于后续需要用到intensity进行边界点检测,而如果激光雷达发出了NaN数据,在后续归一化时会出现错误,所以需要提出NaN数据。但不幸的是,PCL自带的去除NaN数据函数removeNaNFromPointCloud不支持自定义的"PointXYZRID"数据类型,因此不能直接调用。只能自己手写一个判断每个点是不是NaN。

3.4.3 点云变换

拿到点云后,首先将Lidar系下的点云,通过transform函数大致变换到相机坐标系。这一步是为了后续点云能够在相机中有个大致的位置,方便相机画图显示。此时,大致的变换参数是我们估计的,采用"conf/config_file.txt"下给出的初始旋转和平移进行。这里的平移和旋转,是“相机系经过该平移旋转到雷达系”,即“雷达系下的坐标点通过旋转平移后成为相机系下的坐标”。而参数文件中第12行的三个角度,是ZYX欧拉角形式,eularAngle(2,1,0),单位是弧度。可以通过计算得到的旋转矩阵,判断给的旋转角度对不对。

这里补充一句,一定要注意激光雷达的坐标系定义,和相机的坐标系定义。这里相机的坐标系定义为:z轴沿镜头向外,y轴向下,x轴由右手系定义。

3.4.4 点云边界点检测

边界点检测是intensityByRangeDiff()函数提供,得到场景中所有的边界点。这个函数通过判断同一个ring上一定范围内符合intensity的变化强度,确定边界点。然而这里的intensity具体就是采用的range,即距离。具体如下:

从每一圈第2个点开始,判断与前一个以及后一个点的range的差值,当作强度。之后调用normalizeIntensity函数进行归一化(注:这里要求所有的距离是有效的,如果测到极端距离,例如很远点或者NaN点,则归一化效果不佳)。归一化后,保留强度大于一定阈值且在xyz给定的范围内的点,阈值和xyz的范围从"conf/config_file.txt"第2-5行给出。这里吐槽一句,在前面我intensity转化了半天,后来根本没用到;另外明明是根据range进行检测,这里非叫做intensity,造成里不小的理解歧义,可能是为了方便使用PointXYZI格式吧。

提取完边界后,返回场景中所有的边界点。下一步就需要进行检测角点。

3.4.5 点云中纸板角点检测

角点检测是getCorners()函数。吐槽一下这个函数内部,opencv的代码写的贼烂,各种不明含义的mask和来回赋值。

第一步将整个场景中的边界点投影到相机图像中,函数为project,即“相机图像能够看到哪些激光雷达的边界点”。这里用到了相机的投影矩阵,来自从"conf/config_file.txt"第8-10行读取的config参数。

第二步手动标注纸板的边界点。由于场景中有许多边界点,需要知道哪些是纸板哪条边的边界点。因此采用手动标注的方式。运行到这一步后,会出现cloud窗口和polygon窗口,前者显示在图像视角下看到的边界点。我们在cloud窗口中按顺序点4个点,形成一个多边形(polygon),框住纸板的一条边界,每次单击后按任意键存储坐标。点完4个点后,polygon窗口会画出所绘制的多边形,以及这个多边形框住的边界点。这样完成了第一条边界的框选,然后按顺序框选其他3条边界。
图:左侧为边界点检测,右侧为框选出的第一条边界

注意1:如果没有激光雷达扫描的边界点没有按照预期投影到相机中,请检查初始给的相机和雷达估计外参(主要是旋转)是否合理。
注意2:这里框选一条边时按照顺时针或逆时针顺序形成多边形,不能较差,否则采用opencv判断某个点是否落入多边形时会出现问题;
注意3:框选4条边时要按照顺时针顺序,这个顺序应该和aruco_mapping检测的marker的旋转一致。如果我们按照官方代码给出的图片中marker的摆放方式,即x轴朝向“左上”,z轴“右上”,如下图,那么在标注雷达点的边界时首先标左上这条边,再标右上这条边。因为要求4个角点要按顺序依次对应,而代码后续寻找雷达的角点时存储的顺序依次是前后两次标注的边界的交点,后续find_transformation()函数中相机系下角点的顺序是右上-右下-左下-左上的顺序。

第三步,注意这里有一个迭代。这个迭代是最外侧callback_noCam()中迭代的,即 3.4.1~3.4.6迭代,而非在这个函数中迭代,迭代次数是MAX_ITERATION次,默认为100。迭代时,我们第一次标注了4条边界的4个polygon,后续就自动提取这4个多边形中的边界点,和marker的边界点,进行计算。

第四步,计算纸板角点。由于上一步提取出了每个边界,那么计算每个边界3维直线参数,采用的是PCL的SampleConsensusModelLine方法进行直线拟合,之后采用PCL的lineToLineSegment计算相邻两条直线的中垂线,并取中点作为角点。

至此,完成了一次迭代中纸板角点的提取。第五步,将提取的3D角点存储到临时文件"/conf/points.txt"中,有n个marker就存储n*4行角点。

3.4.6 相机中纸板角点的检测与外参计算

相机系下纸板角点检测和外参计算都在函数find_transformation()中。这个函数需要接收aruco_mapping节点发出的marker的消息lidar_camera_calibration_rt,并从上述的"/conf/points.txt"文件中读取lidar下角点,进行配准。

第一步,计算角点在相机中的三维坐标。直接读取aruco的参数,之后根据参数以及board和marker的尺寸(由“conf/marker_coordinates.txt"下几行数据定义)计算出角点,就是从这里我们能够判断出几个角点在相机下的坐标。计算方式很简单,由于marker定义了xz平面,所以同一平面的纸板的四个角的y坐标都是0,xz坐标可以通过markder的margin和纸板的尺寸确定。确定后,所有点数据又以追写的方式存在了临时文件"/conf/points.txt"当中。吐槽一下,真的是瞎传参。

第二步,由readArray()函数读取"/conf/points.txt"中所有角点在lidar和camera下的三维坐标。

第三步,由calc_RT()函数迭代计算外参。这个函数的计算,就是典型的ICP算法,首先计算平移,然后SVD分解计算R。这里的Rt是camera系变化到lidar系,即lidar系下坐标转移到camera系的Rt。

正如3.4.5中所说,共进行了100次迭代,每次迭代时都经过了3.4.6这几个函数,计算出一次camera系到lidar系的Rt,然后100次后结束迭代,求解平均的Rt。这里需要注意,程序输出了好几个Rt和T,计算的平均R起始只是在初始给定的旋转下的增量,并不是实际的R,而完整的T中所包含的左上角3x3矩阵才是真正的外参R,右上角3x1的是外参t。注意,这里的迭代100次,用的是全局变量,即在3.4.2中首次标注多边形时iteration_counter就是这个全局变量。这里又在用全局变量传参。


最终结果:红框内为真实的Rt


激光雷达在相机的y轴方向5cm处,基本认为标定正确,只是还不准。后续再提升。

有关【学习总结】激光雷达与相机外参标定:原理与代码1的更多相关文章

  1. ruby - 如何在 buildr 项目中使用 Ruby 代码? - 2

    如何在buildr项目中使用Ruby?我在很多不同的项目中使用过Ruby、JRuby、Java和Clojure。我目前正在使用我的标准Ruby开发一个模拟应用程序,我想尝试使用Clojure后端(我确实喜欢功能代码)以及JRubygui和测试套件。我还可以看到在未来的不同项目中使用Scala作为后端。我想我要为我的项目尝试一下buildr(http://buildr.apache.org/),但我注意到buildr似乎没有设置为在项目中使用JRuby代码本身!这看起来有点傻,因为该工具旨在统一通用的JVM语言并且是在ruby中构建的。除了将输出的jar包含在一个独特的、仅限ruby​​

  2. ruby-on-rails - Rails 源代码 : initialize hash in a weird way? - 2

    在rails源中:https://github.com/rails/rails/blob/master/activesupport/lib/active_support/lazy_load_hooks.rb可以看到以下内容@load_hooks=Hash.new{|h,k|h[k]=[]}在IRB中,它只是初始化一个空哈希。和做有什么区别@load_hooks=Hash.new 最佳答案 查看rubydocumentationforHashnew→new_hashclicktotogglesourcenew(obj)→new_has

  3. ruby-on-rails - 浏览 Ruby 源代码 - 2

    我的主要目标是能够完全理解我正在使用的库/gem。我尝试在Github上从头到尾阅读源代码,但这真的很难。我认为更有趣、更温和的踏脚石就是在使用时阅读每个库/gem方法的源代码。例如,我想知道RubyonRails中的redirect_to方法是如何工作的:如何查找redirect_to方法的源代码?我知道在pry中我可以执行类似show-methodmethod的操作,但我如何才能对Rails框架中的方法执行此操作?您对我如何更好地理解Gem及其API有什么建议吗?仅仅阅读源代码似乎真的很难,尤其是对于框架。谢谢! 最佳答案 Ru

  4. ruby - 模块嵌套代码风格偏好 - 2

    我的假设是moduleAmoduleBendend和moduleA::Bend是一样的。我能够从thisblog找到解决方案,thisSOthread和andthisSOthread.为什么以及什么时候应该更喜欢紧凑语法A::B而不是另一个,因为它显然有一个缺点?我有一种直觉,它可能与性能有关,因为在更多命名空间中查找常量需要更多计算。但是我无法通过对普通类进行基准测试来验证这一点。 最佳答案 这两种写作方法经常被混淆。首先要说的是,据我所知,没有可衡量的性能差异。(在下面的书面示例中不断查找)最明显的区别,可能也是最著名的,是你的

  5. ruby - 寻找通过阅读代码确定编程语言的ruby gem? - 2

    几个月前,我读了一篇关于ruby​​gem的博客文章,它可以通过阅读代码本身来确定编程语言。对于我的生活,我不记得博客或gem的名称。谷歌搜索“ruby编程语言猜测”及其变体也无济于事。有人碰巧知道相关gem的名称吗? 最佳答案 是这个吗:http://github.com/chrislo/sourceclassifier/tree/master 关于ruby-寻找通过阅读代码确定编程语言的rubygem?,我们在StackOverflow上找到一个类似的问题:

  6. ruby - Net::HTTP 获取源代码和状态 - 2

    我目前正在使用以下方法获取页面的源代码:Net::HTTP.get(URI.parse(page.url))我还想获取HTTP状态,而无需发出第二个请求。有没有办法用另一种方法做到这一点?我一直在查看文档,但似乎找不到我要找的东西。 最佳答案 在我看来,除非您需要一些真正的低级访问或控制,否则最好使用Ruby的内置Open::URI模块:require'open-uri'io=open('http://www.example.org/')#=>#body=io.read[0,50]#=>"["200","OK"]io.base_ur

  7. 程序员如何提高代码能力? - 2

    前言作为一名程序员,自己的本质工作就是做程序开发,那么程序开发的时候最直接的体现就是代码,检验一个程序员技术水平的一个核心环节就是开发时候的代码能力。众所周知,程序开发的水平提升是一个循序渐进的过程,每一位程序员都是从“菜鸟”变成“大神”的,所以程序员在程序开发过程中的代码能力也是根据平时开发中的业务实践来积累和提升的。提高代码能力核心要素程序员要想提高自身代码能力,尤其是新晋程序员的代码能力有很大的提升空间的时候,需要针对性的去提高自己的代码能力。提高代码能力其实有几个比较关键的点,只要把握住这些方面,就能很好的、快速的提高自己的一部分代码能力。1、多去阅读开源项目,如有机会可以亲自参与开源

  8. 7个大一C语言必学的程序 / C语言经典代码大全 - 2

    嗨~大家好,这里是可莉!今天给大家带来的是7个C语言的经典基础代码~那一起往下看下去把【程序一】打印100到200之间的素数#includeintmain(){ inti; for(i=100;i 【程序二】输出乘法口诀表#includeintmain(){inti;for(i=1;i 【程序三】判断1000年---2000年之间的闰年#includeintmain(){intyear;for(year=1000;year 【程序四】给定两个整形变量的值,将两个值的内容进行交换。这里提供两种方法来进行交换,第一种为创建临时变量来进行交换,第二种是不创建临时变量而直接进行交换。1.创建临时变量来

  9. LC滤波器设计学习笔记(一)滤波电路入门 - 2

    目录前言滤波电路科普主要分类实际情况单位的概念常用评价参数函数型滤波器简单分析滤波电路构成低通滤波器RC低通滤波器RL低通滤波器高通滤波器RC高通滤波器RL高通滤波器部分摘自《LC滤波器设计与制作》,侵权删。前言最近需要学习放大电路和滤波电路,但是由于只在之前做音乐频谱分析仪的时候简单了解过一点点运放,所以也是相当从零开始学习了。滤波电路科普主要分类滤波器:主要是从不同频率的成分中提取出特定频率的信号。有源滤波器:由RC元件与运算放大器组成的滤波器。可滤除某一次或多次谐波,最普通易于采用的无源滤波器结构是将电感与电容串联,可对主要次谐波(3、5、7)构成低阻抗旁路。无源滤波器:无源滤波器,又称

  10. SPI接收数据异常问题总结 - 2

    SPI接收数据左移一位问题目录SPI接收数据左移一位问题一、问题描述二、问题分析三、探究原理四、经验总结最近在工作在学习调试SPI的过程中遇到一个问题——接收数据整体向左移了一位(1bit)。SPI数据收发是数据交换,因此接收数据时从第二个字节开始才是有效数据,也就是数据整体向右移一个字节(1byte)。请教前辈之后也没有得到解决,通过在网上查阅前人经验终于解决问题,所以写一个避坑经验总结。实际背景:MCU与一款芯片使用spi通信,MCU作为主机,芯片作为从机。这款芯片采用的是它规定的六线SPI,多了两根线:RDY和INT,这样从机就可以主动请求主机给主机发送数据了。一、问题描述根据从机芯片手

随机推荐