一、ArUco之Marker Mapper

1、Marker Mapper简介

Mapping and Localization from Planar Markers是A.V.A小组基于ArUco开发的一个利用二维码建图与定位的项目。

论文地址:Mapping and Localization from Planar Markers
源码地址:https://sourceforge.net/projects/markermapper/files/?source=navbar (源码持续更新)

2、利用MarkerMapper实现建图

(1)编译源码

我们从源码地址下载当前最新的1.0.12版本,解压编译:

$ unzip marker_mapper1.0.12.zip
$ cd marker_mapper1.0.12
$ mkdir build
$ cd build
$ cmake ..
$ make

如果编译出错,打开marker_mapper1.0.12文件夹下的CMakeLists.txt,做如下修改:

修改完以后重新编译即可:$ cmake ..; make

(2)用自己的摄像头建图

先找到你之前标定相机生成的yml文件,将其拷贝到 build/utils文件夹下,然后在此文件夹下运行命令$ ./mapper_from_video,结果如下:

解释一下这几个参数:

  • (live | intput-video.avi[:v2.avi[:v3.avi…]]):live表示采用摄像头设备,但这里只能打开设备号为0的相机设备,如果你用笔记本想打开USB摄像头的话需要改一下源码。具体怎么改我就不说了,相信这点小问题肯定难不倒大家。后面input-video.avi表示读取视频文件,你需要事先录取一段视频,然后最好也放到 build/utils文件夹下。
  • camera_parameters.yml:相机的标定文件。
  • marker_size(meters):二维码的边长(以米为单位)。请务必保证所有二维码的边长一致,不要出现大小不一的二维码。
  • [-ref reference_marker_id]:参考二维码的id。在建图过程中需要选定一个二维码作为基准,其他二维码的位置均是相对于这个二维码来说的。即以参考二维码的坐标系作为固定的世界坐标系。缺省条件下以第一个被识别的二维码为基准。
  • [-fi frameIncrement]:帧增量,缺省值为1。
  • [-out basename]:程序输出使用的基本名称。程序会以“basename”生成basename.pcd、basename.log、basename.yml、basename-cam.yml四个文件。缺省值为“markerset”。
  • [-d maker_dictionary] [-c arucoConfig.yml]:两者二选一,指定二维码的字典或指定一个二维码的配置文件。-d的缺省值是ALL_DICTS,表示可以识别ArUco库内所有字典中的二维码。
  • -noshow:这个参数没有在Usage中体现出来,它表示在程序最后不启动MapperViewer。

根据上面对每个参数的解释,我们可以根据自己的实际情况输入合适的命令行参数,以我的为例:

$ ./mapper_from_video live out_camera_calibration.yml 0.135 -ref 10 -d ARUCO -noshow

运行效果如下:

我把二维码的前三个角点按照黄、绿、蓝的顺序标了出来,你的程序中应该不会有这个效果,因为我改了ArUco的源码。程序运行时,为确保建图的精度,相机移动不宜过快,且应尽量保证在运动过程中每一帧图像至少有两个二维码。按’s’键暂停,'ESC’键退出。程序运行结束后,你会发现文件夹中多了四个文件:

  • markerset.log:记录了关键帧的相机位姿,数据格式为:#frame_id tx ty tz qx qy qz qw。
  • markerset.pcd:二维码分布及相机关键帧的点云图。
  • markerset.yml:二维码的分布文件,这个文件描述了所有二维码相对于参考二维码的空间位姿。以参考二维码的中心为基准,以该二维码的坐标系为世界坐标系,所有二维码角点的三维空间坐标便确定了。
  • markerset-cam.yml:优化以后的相机标定文件。

我们用PCL看一下生成的点云图,运行命令$ pcl_viewer markerset.pcd

如上图所示,我在实验室墙壁上贴了20张ARUCO字典中的marker,marker的ID从0到19。点云图中的marker分布完全重现了真实物理环境中的二维码分布。

(3)用二维码分布图实现室内定位

用MarkerMapper我们得到了二维码分布图,即我们知道了所有二维码之间的相对位姿关系。近一步说,我们以参考二维码的坐标系作为世界坐标系,我们便知道了每个二维码在三维空间中精确的位姿,这是非常重要的!

由上一篇我们知道,利用一个二维码可以估计相机的位姿,而现在我们有了好多二维码,并且它们的空间位姿我们也都知道(实际我们已经知道了每个二维码每个角点的三维坐标)。那么,我们是不是可以利用这些二维码来实现相机的位姿估计,进而达到室内定位的目的呢?答案是肯定的!

我们先用ArUco源码中的aruco_test_markermap测试一下看看效果,执行命令:

$ ~/OpenCV/aruco-3.0.6/build/utils_markermap/aruco_test_markermap live:1 markerset.yml markerset-cam.yml -s 0.135

不要照抄,根据你的实际情况做适当修改,运行结果如下图所示:

从图中我们可以看出,相机准确地找到了自己的位置,即很好地实现了相机的位姿估计。接下来,我就在这个程序的基础上做一些删改,按照上一篇的做法将相机在世界坐标系中的位姿数据显示出来。

  • 首先,添加源文件

cd 到上一篇创建的工程目录test/文件夹下,运行命令:

$ touch cam_localization.cpp
$ gedit cam_localization.cpp

然后粘贴以下代码:

/*****************************************************/
/*                   By Li Xujie                     */
/*****************************************************/

#include "cvdrawingutils.h"
#include "aruco.h"
#include "timers.h"
#include <fstream>
#include <iostream>
#include <opencv2/opencv.hpp>
#include <sstream>
#include <string>
#include <stdexcept>

using namespace cv;
using namespace aruco;
using namespace std;

string TheMarkerMapConfigFile;
float TheMarkerSize = -1;
VideoCapture TheVideoCapturer;
Mat TheInputImage, TheInputImageCopy;
CameraParameters TheCameraParameters;
MarkerMap TheMarkerMapConfig;
MarkerDetector TheMarkerDetector;
MarkerMapPoseTracker TheMSPoseTracker;
int waitTime = 0;
int ref_id = 0;

char camPositionStr[100];
char camDirectionStr[100];

class CmdLineParser
{
    int argc;char** argv;public:
    CmdLineParser(int _argc, char** _argv): argc(_argc), argv(_argv){}
    //is the param?
    bool operator[](string param)
    {int idx = -1;   for (int i = 0; i < argc && idx == -1; i++)if (string(argv[i]) == param) idx = i;return (idx != -1); }
    //return the value of a param using a default value if it is not present
    string operator()(string param, string defvalue = "-1"){int idx = -1;for (int i = 0; i < argc && idx == -1; i++)if (string(argv[i]) == param) idx = i;if (idx == -1) return defvalue;else return (argv[idx + 1]);}
};

/************************************
 ************************************/
int main(int argc, char** argv)
{
    try
    {
        CmdLineParser cml(argc, argv);
        if (argc < 4 || cml["-h"])
        {
            cerr << "Invalid number of arguments" << endl;
            cerr << "Usage: (in.avi|live[:camera_index(e.g 0 or 1)])) marksetconfig.yml camera_intrinsics.yml  "
                    "[-s marker_size] [-ref_id reference_marker_id] [-e use EnclosedMarker or not]\n\t" << endl;
            return false;
        }
        TheMarkerMapConfigFile = argv[2];
        TheMarkerMapConfig.readFromFile(TheMarkerMapConfigFile);
        TheMarkerSize = stof(cml("-s", "1"));
        ref_id = std::stoi(cml("-ref_id"));
        if(-1 == ref_id){cout<<"You need indicate a referene_marker by use the parameter [-ref_id].\n"<<endl;return false;}
	
        // read from camera or from  file
        string TheInputVideo=string(argv[1]);
        if ( TheInputVideo.find( "live")!=std::string::npos)
        {
            int vIdx = 0;
            // check if the :idx is here
            char cad[100];
            if (TheInputVideo.find(":") != string::npos)
            {
                std::replace(TheInputVideo.begin(), TheInputVideo.end(), ':', ' ');
                sscanf(TheInputVideo.c_str(), "%s %d", cad, &vIdx);
            }
            cout << "Opening camera index " << vIdx << endl;
            TheVideoCapturer.open(vIdx);
            waitTime = 10;
        }
        else TheVideoCapturer.open(argv[1]);        // check video is open
        if (!TheVideoCapturer.isOpened())throw std::runtime_error("Could not open video");

        // read first image to get the dimensions
        TheVideoCapturer >> TheInputImage;

        // read camera parameters if passed
        TheCameraParameters.readFromXMLFile(argv[3]);
        TheCameraParameters.resize(TheInputImage.size());
        
		// prepare the detector
        TheMarkerDetector.setDictionary( TheMarkerMapConfig.getDictionary());
        TheMarkerDetector.getParameters().detectEnclosedMarkers(std::stoi(cml("-e", "0"))); //If use enclosed marker, set -e 1(true), or set -e 0(false). Default value is 0.

        if (cml["-config"])TheMarkerDetector.loadParamsFromFile(cml("-config"));
        // prepare the pose tracker if possible
        // if the camera parameers are avaiable, and the markerset can be expressed in meters, then go

        if (TheMarkerMapConfig.isExpressedInPixels() && TheMarkerSize > 0)
            TheMarkerMapConfig = TheMarkerMapConfig.convertToMeters(TheMarkerSize);

        cout << "TheCameraParameters.isValid()=" << TheCameraParameters.isValid() << " "<< TheMarkerMapConfig.isExpressedInMeters() << endl;

        if (TheCameraParameters.isValid() && TheMarkerMapConfig.isExpressedInMeters()){
            TheMSPoseTracker.setParams(TheCameraParameters, TheMarkerMapConfig);
            TheMarkerSize=cv::norm(TheMarkerMapConfig[0][0]- TheMarkerMapConfig[0][1]);
        }
	
		char key = 0;
        int index = 0;
        // capture until press ESC or until the end of the video
        cout << "Press 's' to start/stop video" << endl;
        Timer avrgTimer;
        do
        {
            TheVideoCapturer.retrieve(TheInputImage);
            TheInputImage.copyTo(TheInputImageCopy);
            index++;  // number of images captured
            if (index>1) avrgTimer.start();//do not consider first frame that requires initialization
            // Detection of the markers
            vector<aruco::Marker> detected_markers = TheMarkerDetector.detect(TheInputImage,TheCameraParameters,TheMarkerSize);
            // estimate 3d camera pose if possible
            if (TheMSPoseTracker.isValid())
                if (TheMSPoseTracker.estimatePose(detected_markers)) {
				Mat rMatrix,camPosMatrix,camVecMatrix;//定义旋转矩阵,平移矩阵,相机位置矩阵和姿态矩阵
				Rodrigues(TheMSPoseTracker.getRvec(),rMatrix);//获得相机坐标系与世界坐标系之间的旋转向量并转化为旋转矩阵
				camPosMatrix=rMatrix.inv()*(-TheMSPoseTracker.getTvec().t());//计算出相机在世界坐标系下的三维坐标
				Mat vect=(Mat_<float>(3,1)<<0,0,1);//定义相机坐标系下的单位方向向量,将其转化到世界坐标系下便可求出相机在世界坐标系中的姿态
				camVecMatrix=rMatrix.inv()*vect;//计算出相机在世界坐标系下的姿态
		    
				    sprintf(camPositionStr,"Camera Position: px=%f, py=%f, pz=%f", camPosMatrix.at<float>(0,0), 
                        camPosMatrix.at<float>(1,0), camPosMatrix.at<float>(2,0));
                    sprintf(camDirectionStr,"Camera Direction: dx=%f, dy=%f, dz=%f", camVecMatrix.at<float>(0,0), 
                        camVecMatrix.at<float>(1,0), camVecMatrix.at<float>(2,0));
				}  
		 
            if (index>1) avrgTimer.end();
            cout<<"Average computing time "<<avrgTimer.getAverage()<<" ms"<<endl;
	    
            // print the markers detected that belongs to the markerset
            for (auto idx : TheMarkerMapConfig.getIndices(detected_markers))
			{
                detected_markers[idx].draw(TheInputImageCopy, Scalar(0, 0, 255));
				if(ref_id == detected_markers[idx].id) CvDrawingUtils::draw3dAxis(TheInputImageCopy,detected_markers[idx],TheCameraParameters);
		    }
		    putText(TheInputImageCopy,camPositionStr,Point(10,13),CV_FONT_NORMAL,0.5,Scalar(0,255,255));
		    putText(TheInputImageCopy,camDirectionStr,Point(10,30),CV_FONT_NORMAL,0.5,Scalar(0,255,255));
imshow("out",TheInputImageCopy);

key = waitKey(waitTime);
if (key=='s') waitTime = waitTime?0:10;
        } while (key != 27 && TheVideoCapturer.grab());
    }
    catch (std::exception& ex) { cout << "Exception :" << ex.what() << endl;  }
}
  • 然后,修改CMakeLists.txt

在终端运行$ gedit ../CMakeLists.txt,在文档最后粘贴下面两行代码:

add_executable( cam_localization test/cam_localization.cpp )
target_link_libraries( cam_localization ${OpenCV_LIBS} ${aruco_LIBS} )
  • 最后,编译执行

依次运行以下命令:

$ cd ../build
$ cmake ..;make
$ ../bin/cam_localization live:1 ~/OpenCV/marker_mapper1.0.12/build/utils/markerset.yml ~/OpenCV/marker_mapper1.0.12/build/utils/markerset-cam.yml -s 0.135 -ref_id 10 -e 1

第三条命令请根据自己的情况适当修改,该程序的运行效果如下图:

图中黄色字符串显示的是相机在世界坐标系下的位置和姿态,因为前面我用MarkerMapper建图的时候是以ID为10的marker作为参考,所以这里的世界坐标系就是10号二维码的坐标系。

博客写到这儿,我们已经用单目相机实现了基于ArUco的室内定位。可以说,这个定位的精度还是相当令人满意的,如果后期再加上一些滤波优化算法,则可以进一步提高它的定位精度和稳定性。**相对于当前很多其他基于二维码的定位与导航方法,基于ArUco基准标识码的视觉定位不需要人工测量或预先铺设二维码路径,我们只要任意地将ArUco码贴在墙(门)面、天花板、地面或立柱上就可实时获取移动机器人的三维空间坐标和姿态。该方法非常方便、灵活,可以极大地降低人工成本。**但是,它有一个致命的弱点:在实际应用中,我们不可能在室内到处都贴上二维码,换句话说,实际环境中的二维码分布通常是很稀疏的,所以难免会出现图像中没有二维码的情况。那么问题来了,在相机检测不到二维码时如何估计它的位姿?这个问题留给各位大神来解决,如有好的方案还望不吝赐教!

二、ArUco之SPM-SLAM

SPM-SLAM: Squared Planar Marker SLAM,是一个基于ArUco的单目视觉SLAM方案。

项目地址:http://www.uco.es/investiga/grupos/ava/node/58

源码地址:https://sourceforge.net/projects/spm-slam/files/

1、编译运行

$ unzip spm-slam_1.0.1.zip
$ cd spm-slam_1.0.1/
$ mkdir build
$ cd build/
$ cmake ..
$ make

这里下载一个名叫“4_video_office_large”的数据集,解压以后里面是一些图片序列以及相机的标定文件等。因为SPM-SLAM需要输入一个视频,所以你需要先把图片序列转成视频(或者直接将源码改为读取图片序列),然后把视频和相机标定文件拷贝到spm-slam文件夹下。为了防止混乱,我在此新建一个文件夹叫dataset,把视频和标定文件放在这里。然后运行命令:

$ ../build/spm-slam video.avi camera.yml -dic ARUCO -markersize 0.123

效果如下图:

运行完了你会发现在dataset文件夹多了一个markermap.yml文件,里面记录了所有marker的分布情况。那么接下来,我要把它的代码改一下,用它跑我们自己的摄像头。

2、SPM-SLAM跑自己的摄像头

  • Step1 创建源文件

cd 到build文件夹,在终端运行命令:

$ touch ../spm_slam.cpp
$ gedit ../spm_slam.cpp

粘贴以下代码:

/********************************************************************/
/                             By Li Xujie                            /
/********************************************************************/

#include "slam.h"
#include "mapviewer.h"
#include "stuff/timers.h"
#include <Eigen/Geometry>


class CmdLineParser {
    int argc;
    char **argv;
public:
    CmdLineParser(int _argc, char **_argv) : argc(_argc), argv(_argv) {}

    bool operator[](string param) {
        int idx = -1;
        for (int i = 0; i < argc && idx == -1; i++) if (string(argv[i]) == param) idx = i;
        return (idx != -1);
    }

    string operator()(string param, string defvalue = "-1") {
        int idx = -1;
        for (int i = 0; i < argc && idx == -1; i++) if (string(argv[i]) == param) idx = i;
        if (idx == -1) return defvalue; else return (argv[idx + 1]);
    }
};

int main(int argc, char **argv) {
    try {
        CmdLineParser cml(argc, argv);
        if (argc < 3) {
            cerr << "Usage: camIndex  camera_params.yml [-conf configParams.yml] [-dic DICT] [-markersize val]" << endl;
            cerr << "\tcamIndex: camera index" << endl;
            cerr << "\tcamera_params.yml: file with calibration parameters. Use OpenCv tool or ArUco tool." << endl;
            cerr << "\t-conf <file>: specifies a configuration file for the aruco detector. Overrides [-dic and -markersize]" << endl;
            cerr << "\t-markersize <float>: specifies the marker size in meters. Default value is 1." << endl;
            cerr << "\t-dic <string>: indicates the dictionary to be employed. Default value is ARUCO_MIP_36h12" << endl;
            cerr << "\t Possible Dictionaries: ";
            for (auto dict : aruco::Dictionary::getDicTypes()) cerr << dict << " ";
            cerr << endl;
            return -1;
        }
        
        cv::VideoCapture vcap;
        cv::VideoWriter videoout;
        vcap.open(std::stod(argv[1]));
        if (!vcap.isOpened()) throw std::runtime_error("Video not opened");

        ucoslam::Slam Slam;
        ucoslam::ImageParams image_params;
        ucoslam::Params params;
        cv::Mat in_image;
        image_params.readFromXMLFile(argv[2]);
        if (cml["-conf"]) params.readFromYMLFile(cml("-conf"));
        else {
            params.aruco_DetectorParams.dictionary = cml("-dic", "ARUCO_MIP_36h12");
            params.aruco_markerSize = stof(cml("-markersize", "1"));
        }

        auto TheMap = std::make_shared<ucoslam::Map>();

        //Create the viewer to see the images and the 3D
        auto TViewer = ucoslam::MapViewer::create("Cv");
        TViewer->set("showNumbers", "1");
        TViewer->set("canLeave", "1");
        TViewer->set("mode", "0");
        TViewer->set("modelMatrix", "0.998437 -0.0490304 0.0268194 0  0.00535287 0.561584 0.827403 0  -0.0556289 -0.825967 0.560969 0  0 0 0 1");
        TViewer->set("viewMatrix", " 1 0 0 0.01  0 4.63287e-05 -1 0.910185  0 1 4.63287e-05 9.18  0 0 0 1 ");

        Slam.setParams(TheMap, params);

        //Ok, let's start
        char k = 0;
        ucoslam::TimerAvrg Fps;
		int currentFrameIndex=0;

        while (vcap.grab() && k != 27) {
            vcap.retrieve(in_image);
            currentFrameIndex++;
            //cout << "currentFrameIndex = " << currentFrameIndex << endl;
            Fps.start();
            Slam.process(in_image, image_params, currentFrameIndex);
            Fps.stop();
            k = TViewer->show(TheMap, in_image, Slam.getCurrentPose_f2g(), "#" + std::to_string(currentFrameIndex) + " fps=" + to_string(1. / Fps.getAvrg()));
        }

        cerr << "The markermap is saved to markermap.yml" << endl;
        TheMap->saveToMarkerMap("markermap.yml");
    }
    catch (std::exception &ex) { cerr << ex.what() << endl; }
}
  • Step2 修改CMakeLists.txt

在终端运行命令$ gedit ../CMakeLists.txt,先在文档中找到“ADD_EXECUTABLE(spm-slam spm-slam.cpp )”,然后做如下修改:

  • Step3 编译运行

把相机标定文件拷贝到build文件夹下,在终端执行命令:

$ cmake ..;make
$ ./spm_slam 1 Logitech.yml -dic ARUCO -markersize 0.135

程序入口参数请根据你的实际情况适当修改,运行效果如下图:

你应该能够发现,程序最后的运行效果跟前面的MarkerMapper差不多,它也会生成一个二维码的分布文件叫markermap.yml。这个文件和MarkerMapper生成的markerset.yml是一样的,都可以用于室内定位。

这里再总结一下,SPM-SLAM相比于其他的视觉SLAM来说,它不存在明显的尺度漂移且没有其他单目SLAM的通病——尺度不确定性。原因很简单,SPM-SLAM预先知道了环境中的一个条件——二维码的边长,这就使得它对整个空间的尺度有了准确地把握;再加上每个二维码四个角点的相对位置已知,这使得相机在运动过程中可以有效地消除累积误差。

三、MarkerMapper的建图原理

本来想在这儿简单地介绍一下原理,但考虑到问题的复杂性,一句两句总也说不清楚,所以我又追加了一篇博客,详细介绍MarkerMapper的实现原理,感兴趣的请移步基于ArUco的视觉定位(四)