OpenCV拾趣(七)——实现相机标定工具

本篇简介

在系列的第五小节我们曾提到过,在需要与现实场景进行交互的应用中,例如在进行AR绘制、3D场景重建时,作为连接现实世界与图像世界的纽带,相机的参数起着至关重要的作用。而相机标定工具的目的,就是通过对标准模式图像的处理,计算出相机的各类参数。
本小节我们就来使用QCvUtils基础包实现一个相机标定工具。

calib_result.png

原理简介

如系列第五小节所述,相机的标定数据可以分为两类:

  • 内部参数(intrinsic parameters):包括相机的焦距、相平面中心坐标和扭曲参数等相机本身属性参数。
  • 外部参数(extrinsic parameters):相机相对世界坐标系的位置信息,包括相对于三个坐标轴的旋转和相对于原点的平移。

具体而言,借用OpenCV官方相机校准说明文档的一张公式图:

calib-model.png

公式描述了真实世界坐标(大写的XYZ)到图像坐标(uv)的映射。不同拍摄角度下的相机外部参数均不相同,需要在实际的场景中才能确定。而内部参数与拍摄场景无关,可以通过一组从标准模式(例如棋盘格)中采集的真实坐标和图像坐标,事先求解计算出来。我们本篇要实现的相机标定工具,就是要实现:

  • 标定坐标数据的便捷采集
    以及
  • 相机内部参数的自动计算和导出

具体的计算原理在这里就不作详细展开了,有兴趣的话可以参考官方文档的相关说明。接下来我们重点关注如何使用OpenCV的Calib3D工具包和我们的QCvUtils基础包实现这个标定工具。

实现棋盘格角点检测滤波器QCvEdgePtFilter

秉承上文所述,我们首先要实现标定坐标数据的便捷采集。对此,我们需要对视频流中出现的棋盘格进行处理。为此,需要实现的主要功能为:

  • 根据棋盘格行、列数配置,检测采集到图像帧棋盘格的角点,并将检测到的角点坐标发送给内参计算模块

按照先前已实现各类场景的思路,我们首先来实现供QCvCamView控件使用的角点检测滤波器QCvEdgePtFilter,声明如下:

class QCvEdgePtFilter : public QCvMatFilter
{
    Q_OBJECT
  public:
    explicit QCvEdgePtFilter(QString name, QObject* parent = nullptr) : QCvMatFilter(name, parent) {}
    virtual ~QCvEdgePtFilter() { m_pointbuf.clear(); }

  public:
    void setColCnt(const int& colCnt) { m_colCnt = colCnt; }
    void setRowCnt(const int& rowCnt) { m_rowCnt = rowCnt; }

  signals:
    void imgPtsCaptured(const std::vector<cv::Point2f>& imgPts,
                        const int& colCnt, const int& rowCnt,
                        const cv::Size& imgSize);

  public slots:
    void onCapture();

  protected:
    virtual void execFilter(const cv::Mat& inMat, cv::Mat& outMat);

  private:
    int m_colCnt;
    int m_rowCnt;
    std::vector<cv::Point2f> m_pointbuf;
    cv::Size m_imgSize;
};

除了QCvMatFilter要求实现的执行滤波方法execFilter外,从声明中可以看出这个滤波器还实现了以下功能:

  • 提供输入棋盘格行、列数量的入口,即各类setter方法
  • 提供棋盘角点的发送出口,借助Qt的信号机制实现(imgPtsCaptured信号)
  • 触发数据采集的槽onCapture

execFilter方法具体实现如下:

void QCvEdgePtFilter::execFilter(const cv::Mat& inMat, cv::Mat& outMat)
{
    if (m_colCnt <= 1 || m_rowCnt <= 1)
    {
        qDebug() << "BOARD SIZE EMPTY!";
        outMat = inMat.clone();
    }

    cv::Mat frameGray;
    cv::cvtColor(outMat, frameGray, cv::COLOR_BGR2GRAY);
    //-- Detect corners
    m_pointbuf.clear();
    bool found = cv::findChessboardCorners(frameGray, cv::Size(m_colCnt, m_rowCnt),
                                           m_pointbuf, cv::CALIB_CB_ADAPTIVE_THRESH |
                                                           cv::CALIB_CB_FAST_CHECK);
    if (found)
    {
        cv::cornerSubPix(frameGray, m_pointbuf, cv::Size(11, 11),
                         cv::Size(-1, -1),
                         cv::TermCriteria(cv::TermCriteria::EPS + cv::TermCriteria::COUNT,
                                          30, 0.1));
        m_imgSize = outMat.size();
        for (size_t i = 0; i < m_pointbuf.size(); i++)
        {
            cv::ellipse(outMat, m_pointbuf[i],
                        cv::Size(5, 5), 0, 0, 360,
                        cv::Scalar(255, 0, 255), 4, 8, 0);
        }
    }
}

这其中核心的处理就是OpenCV提供的findChessboardCorners方法,将灰度图像及棋盘格大小传入后,这个方法会检测出图中所有的角点,并将坐标按顺序存储到缓存数组m_pointbuf中。
如果从采集到的图像帧中顺利检测到了棋盘格角点,滤波器还会进一步使用角点亚像素精细化方法(cornerSubPix)提升角点坐标精度,之后将计算得到的角点绘制在图像帧上。

完成了上面的实现,在需要采集标定坐标数据时,只需要将缓存数组中的数据发送出去就可以了:

void QCvEdgePtFilter::onCapture()
{
    if(!m_pointbuf.empty())
    {
        emit imgPtsCaptured(m_pointbuf, m_colCnt, m_rowCnt, m_imgSize);
    }
}

注: 需要特别说明的是,为了避免边界问题,OpenCV处理棋盘格图像时会舍弃最外圈的一层棋盘格,因此棋盘格左上角会从第二行第二列的格子开始计算。后文将依照此约定进行说明。

实现相机标定器CamCalibrator

完成了标定坐标的采集,下一步自然是实现对所采集数据进行处理的模块,也就是下面所声明的相机标定器CamCalibrator类:

class CamCalibrator : public QObject
{
    Q_OBJECT
  public:
    explicit CamCalibrator(QObject* parent = nullptr);

  public:
    void setSquareWidth(const float& width) { m_squareWidth = width; }
    void setSquareHeight(const float& height) { m_squareHeight = height; }

  signals:
    void calibrated(QString result);

  public slots:
    void onImgPts(const std::vector<cv::Point2f>& imgPts,
                  const int& colCnt, const int& rowCnt,
                  const cv::Size& imgSize);
    void reset();
    void saveResults(QString fileName);

  private:
    template <typename T>
    void checkAndPushData(std::vector<T>& vector, const T& data);
    void runCalibration(const cv::Size& imgSize);

    void showParams();

  private:
    float m_squareWidth;
    float m_squareHeight;

    std::vector<std::vector<cv::Point2f>> m_imgPts;
    std::vector<std::vector<cv::Point3f>> m_objPts;

    cv::Mat m_cameraMatrix;
    cv::Mat m_distCoeffs;
    double m_rms;
};

这其中设置棋盘格真实宽高的两个setter方法和一些零散的工具方法就不在这里展开说明了,我们重点看一下实现核心功能的几个方法。首先是接收上文imgPtsCaptured信号的槽方法onImgPts:

void CamCalibrator::onImgPts(const std::vector<cv::Point2f>& imgPts,
                             const int& colCnt, const int& rowCnt,
                             const cv::Size& imgSize)
{
    if (m_squareHeight >= 0 && m_squareWidth >= 0 && imgPts.size() > 0)
    {
        checkAndPushData(m_imgPts, imgPts);
        //calculate object points;
        std::vector<cv::Point3f> corners;
        for (int i = 0; i < rowCnt; i++)
        {
            for (int j = 0; j < colCnt; j++)
            {
                corners.push_back(cv::Point3f(float(j * m_squareHeight),
                                              float(i * m_squareWidth), 0));
            }
        }
        checkAndPushData(m_objPts, corners);
        runCalibration(imgSize);
        showParams();
    }
}

可以看到这个方法调用了其他三个方法,这三个方法对应了处理标定坐标数据的四个基本步骤:

  1. 将采集到的图像坐标点添加到图像坐标缓存中( checkAndPushData(m_imgPts, imgPts) );
  2. 根据配置的棋盘格真实大小,依次计算角点在真实世界的坐标。这里为了方便计算,将真实世界的坐标系原点设定在了棋盘格左上角(即第二行第二列格子的左上角,之后不再赘述)。计算得到的结果将被添加到真实坐标缓存中( checkAndPushData(m_objPts, corners) );
  3. 读取缓存中所有的坐标,执行标定计算
  4. 将标定结果展示到界面上

这几个步骤中最重要的执行标定操作实现如下:

void CamCalibrator::runCalibration(const cv::Size& imgSize)
{
    std::vector<cv::Mat> rvecs, tvecs;
    m_cameraMatrix = cv::Mat::eye(3, 3, CV_64F);
    m_distCoeffs = cv::Mat::zeros(8, 1, CV_64F);
    m_rms = cv::calibrateCamera(m_objPts, m_imgPts,
                                imgSize, m_cameraMatrix, m_distCoeffs, rvecs, tvecs);
}

也就是使用了OpenCV提供的calibrateCamera方法。标定的计算结果将缓存到m_cameraMatrix和m_distCoeffs变量中,同时标定结果的误差参考值会被缓存到m_rms变量里。

而最终标定结果的展示,通过向主界面发射calibrated信号实现,这里就不作展开说明了。

相机标定器的另一个重要功能是标定结果的导出。这里我们使用了OpenCV提供的FileStorage工具,可以很方便地以统一的存储格式实现标定数据的读写,具体如下:

void CamCalibrator::saveResults(QString fileName)
{
    cv::FileStorage fs(fileName.toStdString().c_str(), cv::FileStorage::WRITE);
    fs << "calibration_time" << QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss").toStdString().c_str();
    fs << "nframes" << (int) m_objPts.size();
    fs << "rms" << m_rms;
    fs << "camera_matrix" << m_cameraMatrix;
    fs << "distortion_coefficients" << m_distCoeffs;
}

使用这个方法存储得到的标定数据格式如下:

{
    "calibration_time": "2018-03-08 19:21:13",
    "nframes": 11,
    "camera_matrix": {
        "type_id": "opencv-matrix",
        "rows": 3,
        "cols": 3,
        "dt": "d",
        "data": [ 8.4446356735928168e+02, 0.0, 3.1252015703842346e+02,
            0.0, 8.3814268020345060e+02, 2.5160161633730544e+02, 0.0,
            0.0, 1.0 ]
    },
    "distortion_coefficients": {
        "type_id": "opencv-matrix",
        "rows": 5,
        "cols": 1,
        "dt": "d",
        "data": [ 2.8536755267484618e-02, 8.9133754139324750e-01,
            9.8503195767350336e-04, 5.1203070506036933e-03,
            -3.0186480045338673e+00 ]
    }
}

界面实现

完成了两个核心功能模块的开发,最后我们通过界面将这些功能串起来。
整体界面设计如下:


calib_gui.png

其中左侧嵌入视频流控件,右上提供棋盘格配置的入口,右下为标定的各类操作按钮和标定结果展示的控件。具体界面布局相关的实现细节就不详细说明了,这里列举几个关键的实现:
首先是在视频流控件中添加角点检测滤波器,并将棋盘格行列数设置控件与滤波器提供的设置入口绑定:

    QString edgePtFilterName = "EdgePt";
    QCvEdgePtFilter* filter = new QCvEdgePtFilter(edgePtFilterName);
    int boardCol = ui->editBoardCol->text().toInt();
    int boardRow = ui->editBoardRow->text().toInt();
    filter->setColCnt(boardCol);
    filter->setRowCnt(boardRow);
    filter->setEnabled(true);

    m_camView->appendFilter(filter);
    connect(ui->editBoardCol, &QLineEdit::textChanged, this, [this, filter](QString newCol) {
        filter->setColCnt(newCol.toInt());
    });
    connect(ui->editBoardRow, &QLineEdit::textChanged, this, [this, filter](QString newRow) {
        filter->setRowCnt(newRow.toInt());
    });

与此类似,将棋盘格真实宽度的设置控件与相机标定器的设置入口绑定:

    CamCalibrator* calibrator = new CamCalibrator(this);
    calibrator->setSquareWidth(ui->editSquareWidth->text().toFloat());
    calibrator->setSquareHeight(ui->editSquareHeight->text().toFloat());
    connect(ui->editSquareHeight, &QLineEdit::textChanged, this, [this, calibrator](QString newH) {
        calibrator->setSquareHeight(newH.toFloat());
    });
    connect(ui->editSquareWidth, &QLineEdit::textChanged, this, [this, calibrator](QString newW) {
        calibrator->setSquareWidth(newW.toFloat());
    });

然后将滤波器发送数据的信号与标定器接收数据的槽连接起来:

connect(filter, &QCvEdgePtFilter::imgPtsCaptured, calibrator, &CamCalibrator::onImgPts);

在点击采集按钮时,触发滤波器的采集操作:

connect(ui->btnCapture, &QPushButton::clicked, this, [this, filter](){
       ... 
       // 省略界面控件和布局相关操作
       filter->onCapture();
    });

最后,将存储参数按钮绑定到标定器提供的存储方法:

connect(ui->btnSave, &QPushButton::clicked, this, [this, calibrator](){
        m_camView->onStreamSwitch(false);
        QString timeStample = QDateTime::currentDateTime().toString("yyyyMMddHHmmss");
        QString saveFileName = QFileDialog::getSaveFileName(this, tr("Save Calibration Results"),
                                                            QString("./%1.json").arg(timeStample),
                                                            tr("Config Files(*.json *.xml *.yml);;All Files(*)"));
        if (!saveFileName.isEmpty())
        {
            calibrator->saveResults(saveFileName);
        }
        m_camView->onStreamSwitch(true);
    });

测试标定效果

使用实现完成的标定工具测试效果如下图:


calib_result.png

注:因为笔者这里使用的是用硬纸板自制的简易标定版,所以计算得到的结果会有比较大的误差。实际场景中使用精度更高的标定板可以得到更精确的标定结果。

有关相机标定工具的实现就说明到这里。下一节我们将使用这些标定参数实现一个较为复杂的应用。

>>本篇参考代码
>>返回系列索引

参考链接

[1] OpenCV官方相机校准说明文档

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335