【ChArUco Marker】标定板检测

https://docs.opencv.org/4.5.4/df/d4a/tutorial_charuco_detection.html

1.ChArUco介绍

ArUco标记和板非常有用,因为它们检测速度快且用途广泛。然而,ArUco标记的一个问题是,即使在应用亚像素精炼之后,其角位置的准确性也不是很高。
相反,棋盘格图案的角点可以更准确地精炼,因为每个角点都被两个黑色方块包围。然而,寻找棋盘格图案不如寻找ArUco板那么通用:它必须完全可见,且不允许有遮挡。
ChArUco试图结合这两种方法的优点:
在这里插入图片描述
ArUco部分用于插值棋盘角的位置,因此它具有标记板的多功能性,因为它允许遮挡或部分视图。此外,由于插值的角属于棋盘,因此在亚像素精度方面非常准确。
在需要高精度的情况下,例如在相机校准中,Charuco 棋盘比标准的 Aruco 棋盘更好。

2.源码分析(opencv 4.5.4)

源码地址:opencv_contrib/modules/aruco/samples/tutorial_charuco_create_detect.cpp

//! [charucohdr]
#include <opencv2/aruco/charuco.hpp>
//! [charucohdr]
#include <opencv2/highgui.hpp>
#include <iostream>
#include <string>

namespace {
const char *about =
    "A tutorial code on charuco board creation and detection of charuco board with and without camera caliberation";
const char *keys = "{c        |       | Put value of c=1 to create charuco board;\nc=2 to detect charuco board without "
                   "camera calibration;\nc=3 to detect charuco board with camera calibration and Pose Estimation}";
} // namespace

void createBoard();
void detectCharucoBoardWithCalibrationPose();
void detectCharucoBoardWithoutCalibration();

static bool readCameraParameters(std::string filename, cv::Mat &camMatrix, cv::Mat &distCoeffs)
{
    cv::FileStorage fs(filename, cv::FileStorage::READ);
    if (!fs.isOpened())
        return false;
    fs["camera_matrix"] >> camMatrix;
    fs["distortion_coefficients"] >> distCoeffs;
    return (camMatrix.size() == cv::Size(3, 3));
}

void createBoard()
{
    cv::Ptr<cv::aruco::Dictionary> dictionary = cv::aruco::getPredefinedDictionary(cv::aruco::DICT_6X6_250);
    //! [createBoard]
    cv::Ptr<cv::aruco::CharucoBoard> board = cv::aruco::CharucoBoard::create(5, 7, 0.04f, 0.02f, dictionary);
    cv::Mat boardImage;
    board->draw(cv::Size(600, 500), boardImage, 10, 1);
    //! [createBoard]
    cv::imwrite("BoardImage.jpg", boardImage);
}

//! [detwcp]
void detectCharucoBoardWithCalibrationPose()
{
    cv::VideoCapture inputVideo;
    inputVideo.open(0);
    //! [matdiscoff]
    cv::Mat cameraMatrix, distCoeffs;
    std::string filename = "calib.txt";
    bool readOk          = readCameraParameters(filename, cameraMatrix, distCoeffs);
    //! [matdiscoff]
    if (!readOk) {
        std::cerr << "Invalid camera file" << std::endl;
    } else {
        //! [dictboard]
        cv::Ptr<cv::aruco::Dictionary> dictionary     = cv::aruco::getPredefinedDictionary(cv::aruco::DICT_6X6_250);
        cv::Ptr<cv::aruco::CharucoBoard> board        = cv::aruco::CharucoBoard::create(5, 7, 0.04f, 0.02f, dictionary);
        cv::Ptr<cv::aruco::DetectorParameters> params = cv::aruco::DetectorParameters::create();
        //! [dictboard]
        while (inputVideo.grab()) {
            //! [inputImg]
            cv::Mat image;
            //! [inputImg]
            cv::Mat imageCopy;
            inputVideo.retrieve(image);
            image.copyTo(imageCopy);
            //! [midcornerdet]
            std::vector<int> markerIds;
            std::vector<std::vector<cv::Point2f>> markerCorners;
            cv::aruco::detectMarkers(image, board->dictionary, markerCorners, markerIds, params);
            //! [midcornerdet]
            // if at least one marker detected
            if (markerIds.size() > 0) {
                cv::aruco::drawDetectedMarkers(imageCopy, markerCorners, markerIds);
                //! [charidcor]
                std::vector<cv::Point2f> charucoCorners;
                std::vector<int> charucoIds;
                cv::aruco::interpolateCornersCharuco(markerCorners,
                                                     markerIds,
                                                     image,
                                                     board,
                                                     charucoCorners,
                                                     charucoIds,
                                                     cameraMatrix,
                                                     distCoeffs);
                //! [charidcor]
                // if at least one charuco corner detected
                if (charucoIds.size() > 0) {
                    cv::Scalar color = cv::Scalar(255, 0, 0);
                    //! [detcor]
                    cv::aruco::drawDetectedCornersCharuco(imageCopy, charucoCorners, charucoIds, color);
                    //! [detcor]
                    cv::Vec3d rvec, tvec;
                    //! [pose]
                    // cv::aruco::estimatePoseCharucoBoard(charucoCorners, charucoIds, board, cameraMatrix, distCoeffs,
                    // rvec, tvec);
                    //! [pose]
                    bool valid = cv::aruco::estimatePoseCharucoBoard(charucoCorners,
                                                                     charucoIds,
                                                                     board,
                                                                     cameraMatrix,
                                                                     distCoeffs,
                                                                     rvec,
                                                                     tvec);
                    // if charuco pose is valid
                    if (valid)
                        cv::aruco::drawAxis(imageCopy, cameraMatrix, distCoeffs, rvec, tvec, 0.1f);
                }
            }
            cv::imshow("out", imageCopy);
            char key = (char)cv::waitKey(30);
            if (key == 27)
                break;
        }
    }
}
//! [detwcp]

//! [detwc]
void detectCharucoBoardWithoutCalibration()
{
    cv::VideoCapture inputVideo;
    inputVideo.open(0);
    cv::Ptr<cv::aruco::Dictionary> dictionary = cv::aruco::getPredefinedDictionary(cv::aruco::DICT_6X6_250);
    cv::Ptr<cv::aruco::CharucoBoard> board    = cv::aruco::CharucoBoard::create(5, 7, 0.04f, 0.02f, dictionary);

    cv::Ptr<cv::aruco::DetectorParameters> params = cv::aruco::DetectorParameters::create();
    params->cornerRefinementMethod                = cv::aruco::CORNER_REFINE_NONE;
    while (inputVideo.grab()) {
        cv::Mat image, imageCopy;
        inputVideo.retrieve(image);
        image.copyTo(imageCopy);
        std::vector<int> markerIds;
        std::vector<std::vector<cv::Point2f>> markerCorners;
        cv::aruco::detectMarkers(image, board->dictionary, markerCorners, markerIds, params);
        // or
        // cv::aruco::detectMarkers(image, dictionary, markerCorners, markerIds, params);
        //  if at least one marker detected
        if (markerIds.size() > 0) {
            cv::aruco::drawDetectedMarkers(imageCopy, markerCorners, markerIds);
            //! [charidcorwc]
            std::vector<cv::Point2f> charucoCorners;
            std::vector<int> charucoIds;
            cv::aruco::interpolateCornersCharuco(markerCorners, markerIds, image, board, charucoCorners, charucoIds);
            //! [charidcorwc]
            // if at least one charuco corner detected
            if (charucoIds.size() > 0)
                cv::aruco::drawDetectedCornersCharuco(imageCopy, charucoCorners, charucoIds, cv::Scalar(255, 0, 0));
        }
        cv::imshow("out", imageCopy);
        char key = (char)cv::waitKey(30);
        if (key == 27)
            break;
    }
}
//! [detwc]

int main(int argc, char *argv[])
{
    cv::CommandLineParser parser(argc, argv, keys);
    parser.about(about);
    if (argc < 2) {
        parser.printMessage();
        return 0;
    }
    int choose = parser.get<int>("c");
    switch (choose) {
        case 1:
            createBoard();
            std::cout << "An image named BoardImg.jpg is generated in folder containing this file" << std::endl;
            break;
        case 2:
            detectCharucoBoardWithoutCalibration();
            break;
        case 3:
            detectCharucoBoardWithCalibrationPose();
            break;
        default:
            break;
    }
    return 0;
}
  • 执行生成charuco图案:./可执行程序 -c=1

3.ChAruco板创建(opencv 4.5.4)

aruco模块提供了cv::aruco::CharucoBoard类,该类表示一个Charuco板,并且继承自Board类。
此类,以及ChArUco功能的其余部分,定义在:

#include <opencv2/aruco/charuco.hpp>

要定义一个 CharucoBoard,需要:

  • X 方向的棋盘格数量。
  • Y 方向的棋盘格数量。
  • 方块边长。
  • 标记边长。
  • 标记字典。
  • 所有标记的 ID。

至于 GridBoard 对象,aruco 模块提供了一个函数来轻松创建 CharucoBoards。这个函数是静态函数 cv::aruco::CharucoBoard::create()

cv::aruco::CharucoBoard board = cv::aruco::CharucoBoard::create(5, 7, 0.04, 0.02, dictionary);
// 第一个参数:表示X方向上的方块数量
// 第二个参数:表示Y方向上的方块数量
// 第三个参数:表示方块的长度即每个棋盘格方块的边长,这里是0.04m
// 第四个参数:表示标记的长度即每个aruco方块的边长,这里是0.02m
// 第五个参数:提供标记的字典。

每个标记的ID默认按升序分配,从0开始,类似于GridBoard::create()。可以通过访问board.ids中的ID向量轻松自定义,类似于Board父类。

一旦我们拥有了CharucoBoard对象,就可以创建一个图像以便打印。这可以通过CharucoBoard::draw()方法来完成:

cv::Ptr<cv::aruco::CharucoBoard> board = cv::aruco::CharucoBoard::create(5, 7, 0.04f, 0.02f, dictionary);
cv::Mat boardImage;
board->draw(cv::Size(600, 500), boardImage, 10, 1);
// 第一个参数:表示输出图像的大小,以像素为单位。在这种情况下为600x500像素。如果这个尺寸与棋盘的尺寸不成比例,它将被居中显示在图像上。  
// 第二个参数:boardImage表示输出的带有棋盘的图像。  
// 第三个参数:(可选的)边距,以像素为单位,以确保没有标记触碰到图像边缘。在这种情况下,边距为10。  
// 第四个参数:表示标记边框的大小,类似于drawMarker()函数。默认值为1。

输出图像如下:
在这里插入图片描述
完整的工作示例包含在模块/aruco/samples/create_board_charuco.cpp中的create_board_charuco.cpp文件内。
注意:create_board_charuco.cpp 现在通过 OpenCV 命令行解析器接受命令行输入。对于这个文件,示例参数将如下所示。

"_ output path_/chboard.png" -w=5 -h=7 -sl=200 -ml=120 -d=10

4.Charuco板检测(opencv 4.5.4)

当你检测到一个ChArUco板时,实际上是在检测板上的每个棋盘角点。

ChArUco板上的每个角点都有一个唯一的标识符(id)分配。这些id从0到板上角点的总数。ChArUco板检测的步骤可以分解为以下几步:

  • 1.输入图像
cv::Mat image;

原始图像,在该图像中将检测标记。此图像在ChArUco角点中执行亚像素精细化是必要的。

  • 2.读取相机标定参数(仅用于带有相机标定的检测)
cv::Mat cameraMatrix, distCoeffs;
std::string filename = "calib.txt";
bool readOk = readCameraParameters(filename, cameraMatrix, distCoeffs);
// readCameraParameters 的参数如下:
// filename:这是 caliberation.txt 文件的路径,该文件是由 calibrate_camera_charuco.cpp 生成的输出文件
// cameraMatrix 和 distCoeffs:可选的相机校准参数
// return:
// 此函数将这些参数作为输入,并返回一个布尔值,指示相机校准参数是否有效。对于未进行校准的角点检测,此步骤不是必需的。
  • 3.检测标记
cv::Ptr<cv::aruco::Dictionary> dictionary = cv::aruco::getPredefinedDictionary(cv::aruco::DICT_6X6_250);
cv::Ptr<cv::aruco::CharucoBoard> board = cv::aruco::CharucoBoard::create(5, 7, 0.04f, 0.02f, dictionary);
cv::Ptr<cv::aruco::DetectorParameters> params = cv::aruco::DetectorParameters::create();

std::vector<int> markerIds;
std::vector<std::vector<cv::Point2f> > markerCorners;
cv::aruco::detectMarkers(image, board->dictionary, markerCorners, markerIds, params);
// detectMarkers的参数如下:
// image:输入图像。
// dictionary:指向将被搜索的字典/标记集的指针。
// markerCorners:检测到的标记角点的向量。
// markerIds:检测到的标记的标识符向量。
// params:标记检测参数 ChArUco角点的检测是基于先前检测到的标记。因此,首先检测标记,然后从标记中插值计算ChArUco角点。

  • 4.从标记插值计算charuco角点

带标定的检测

std::vector<cv::Point2f> charucoCorners;
std::vector<int> charucoIds;
cv::aruco::interpolateCornersCharuco(markerCorners, markerIds, image, board, charucoCorners, charucoIds, cameraMatrix, distCoeffs);

不带标定的检测

std::vector<cv::Point2f> charucoCorners;
std::vector<int> charucoIds;
cv::aruco::interpolateCornersCharuco(markerCorners, markerIds, image, board, charucoCorners, charucoIds);

检测 ChArUco 角点的函数是 cv::aruco::interpolateCornersCharuco()。该函数返回插值的 Charuco 角点的数量。
std::vectorcv::Point2f charucoCorners : 检测到的角点的图像位置列表。
std::vector<int> charucoIds : charucoCorners 中每个检测到的角点的 ID。

如果提供了标定参数,则通过首先从 ArUco 标记估计一个粗略的姿态,然后将 ChArUco 角点重新投影回图像来插值 ChArUco 角点。

另一方面,如果未提供标定参数,则通过计算 ChArUco 平面和 ChArUco 图像投影之间的相应单应性来插值 ChArUco 角点。

使用单应性的主要问题是插值对图像畸变更加敏感。实际上,单应性仅通过每个 ChArUco 角的最近标记来执行,以减少畸变的影响。

在检测 ChArUco 板的标记时,特别是在使用单应性时,建议禁用标记的角点优化。原因在于,由于棋盘格方块的相邻性,子像素处理可能在角点位置产生重要偏差,这些偏差会传播到 ChArUco 角点的插值,导致结果不佳。

此外,仅返回其两个周围标记都被找到的角点。如果两个周围标记中有一个没有被检测到,通常意味着该区域存在某种遮挡或图像质量不佳。在任何情况下,最好不要考虑该角点,因为我们希望确保插值后的 ChArUco 角点非常准确。

在 ChArUco 角点插值后,进行子像素优化。

一旦我们插值了ChArUco角点,我们可能希望绘制它们以查看检测是否正确。这可以使用drawDetectedCornersCharuco()函数轻松完成:

cv::aruco::drawDetectedCornersCharuco(imageCopy, charucoCorners, charucoIds, color);
// imageCopy:是将绘制角落的图像(通常是检测到角落的同一图像)。  
// outputImage:是 inputImage 的克隆,并绘制了角落。  
// charucoCorners 和 charucoIds 是从 interpolateCornersCharuco() 函数中检测到的 Charuco 角落。  
// 最后一个参数是我们想要绘制角落的(可选)颜色,类型为 cv::Scalar。

在这里插入图片描述
在这里插入图片描述
在存在遮挡的情况下,如下图所示,尽管某些角落明显可见,但由于遮挡,并非所有周围标记都已被检测到,因此它们未被插值。
在这里插入图片描述

ChArUco检测源码(不使用标定参数)

最后,这是一个ChArUco检测的完整示例(未使用标定参数):

void detectCharucoBoardWithoutCalibration()
{
    cv::VideoCapture inputVideo;
    inputVideo.open(0);
    cv::Ptr<cv::aruco::Dictionary> dictionary = cv::aruco::getPredefinedDictionary(cv::aruco::DICT_6X6_250);
    cv::Ptr<cv::aruco::CharucoBoard> board = cv::aruco::CharucoBoard::create(5, 7, 0.04f, 0.02f, dictionary);
    cv::Ptr<cv::aruco::DetectorParameters> params = cv::aruco::DetectorParameters::create();
    params->cornerRefinementMethod = cv::aruco::CORNER_REFINE_NONE;
    while (inputVideo.grab()) {
        cv::Mat image, imageCopy;
        inputVideo.retrieve(image);
        image.copyTo(imageCopy);
        std::vector<int> markerIds;
        std::vector<std::vector<cv::Point2f> > markerCorners;
        cv::aruco::detectMarkers(image, board->dictionary, markerCorners, markerIds, params);
        //or
        //cv::aruco::detectMarkers(image, dictionary, markerCorners, markerIds, params);
        // if at least one marker detected
        if (markerIds.size() > 0) {
            cv::aruco::drawDetectedMarkers(imageCopy, markerCorners, markerIds);
            std::vector<cv::Point2f> charucoCorners;
            std::vector<int> charucoIds;
            cv::aruco::interpolateCornersCharuco(markerCorners, markerIds, image, board, charucoCorners, charucoIds);
            // if at least one charuco corner detected
            if (charucoIds.size() > 0)
                cv::aruco::drawDetectedCornersCharuco(imageCopy, charucoCorners, charucoIds, cv::Scalar(255, 0, 0));
        }
        cv::imshow("out", imageCopy);
        char key = (char)cv::waitKey(30);
        if (key == 27)
            break;
    }
}

完整的工作示例包含在 modules/aruco/samples/detect_board_charuco.cpp 中的 detect_board_charuco.cpp 文件中。
在这里插入图片描述

注意:示例现在通过 OpenCV 命令行解析器通过命令行输入。对于此文件,示例参数将如下所示:

-c="_path_/calib.txt" -dp="_path_/detector_params.yml" -w=5 -h=7 -sl=0.04 -ml=0.02 -d=10

这里的 calib.txt 是由 calibrate_camera_charuco.cpp 生成的输出文件。

5.ChArUco姿态估计(opencv 4.5.4)

ChArUco板的最终目标是以非常高的精度找到角点,用于高精度校准或姿态估计。
Aruco模块提供了一个函数,可以轻松执行ChArUco姿态估计。与GridBoard一样,CharucoBoard的坐标系位于板平面上,Z轴向外指向,并以板的左下角为中心。
用于姿态估计的函数是 estimatePoseCharucoBoard():

cv::aruco::estimatePoseCharucoBoard(charucoCorners, charucoIds, board, cameraMatrix, distCoeffs, rvec, tvec);
// charucoCorners 和 charucoIds 参数是从 interpolateCornersCharuco() 函数检测到的 Charuco 角点。
// 第三个参数是 CharucoBoard 对象。
// cameraMatrix 和 distCoeffs 是相机校准参数,对于姿态估计是必要的。
// rvec 和 tvec 参数是 Charuco 板的姿态输出。
// return:
// 如果姿态正确估计,则函数返回 true,否则返回 false。失败的主要原因是没有足够的角点用于姿态估计或它们在同一条线上。

可以使用 drawAxis() 绘制坐标轴,以检查姿态是否正确估计。结果将是:(X: 红色, Y: 绿色, Z: 蓝色)
在这里插入图片描述

ChArUco检测与姿态估计源码

void detectCharucoBoardWithCalibrationPose()
{
    cv::VideoCapture inputVideo;
    inputVideo.open(0);
    cv::Mat cameraMatrix, distCoeffs;
    std::string filename = "calib.txt";
    bool readOk = readCameraParameters(filename, cameraMatrix, distCoeffs);
    if (!readOk) {
        std::cerr << "Invalid camera file" << std::endl;
    } else {
        cv::Ptr<cv::aruco::Dictionary> dictionary = cv::aruco::getPredefinedDictionary(cv::aruco::DICT_6X6_250);
        cv::Ptr<cv::aruco::CharucoBoard> board = cv::aruco::CharucoBoard::create(5, 7, 0.04f, 0.02f, dictionary);
        cv::Ptr<cv::aruco::DetectorParameters> params = cv::aruco::DetectorParameters::create();
        while (inputVideo.grab()) {
            cv::Mat image;
            cv::Mat imageCopy;
            inputVideo.retrieve(image);
            image.copyTo(imageCopy);
            std::vector<int> markerIds;
            std::vector<std::vector<cv::Point2f> > markerCorners;
            cv::aruco::detectMarkers(image, board->dictionary, markerCorners, markerIds, params);
            // if at least one marker detected
            if (markerIds.size() > 0) {
                cv::aruco::drawDetectedMarkers(imageCopy, markerCorners, markerIds);
                std::vector<cv::Point2f> charucoCorners;
                std::vector<int> charucoIds;
                cv::aruco::interpolateCornersCharuco(markerCorners, markerIds, image, board, charucoCorners, charucoIds, cameraMatrix, distCoeffs);
                // if at least one charuco corner detected
                if (charucoIds.size() > 0) {
                    cv::Scalar color = cv::Scalar(255, 0, 0);
                    cv::aruco::drawDetectedCornersCharuco(imageCopy, charucoCorners, charucoIds, color);
                    cv::Vec3d rvec, tvec;
                    // cv::aruco::estimatePoseCharucoBoard(charucoCorners, charucoIds, board, cameraMatrix, distCoeffs, rvec, tvec);
                    bool valid = cv::aruco::estimatePoseCharucoBoard(charucoCorners, charucoIds, board, cameraMatrix, distCoeffs, rvec, tvec);
                    // if charuco pose is valid
                    if (valid)
                        cv::aruco::drawAxis(imageCopy, cameraMatrix, distCoeffs, rvec, tvec, 0.1f);
                }
            }
            cv::imshow("out", imageCopy);
            char key = (char)cv::waitKey(30);
            if (key == 27)
                break;
        }
    }
}

modules/aruco/samples/detect_board_charuco.cpp 中包含了完整的 detect_board_charuco.cpp 示例。
注意:现在示例通过 OpenCV 命令行解析器从命令行接收输入。对于此文件,示例参数将如下所示:

"_path_/calib.txt" -dp="_path_/detector_params.yml" -w=5 -h=7 -sl=0.04 -ml=0.02 -d=10

6.源码分析(opencv 4.10.0)

https://docs.opencv.org/4.10.0/df/d4a/tutorial_charuco_detection.html

可以在 samples/cpp/tutorial_code/objectDetection/detect_board_charuco.cpp 中找到此代码。

功能包含:
1.创建一个 Charuco 板
2.在不进行相机标定的情况下检测 Charuco 角点
3.在进行相机标定和姿态估计的情况下检测 Charuco 角点

在这里插入图片描述

#include <opencv2/highgui.hpp>
//! [charucohdr]
#include <opencv2/objdetect/charuco_detector.hpp>
//! [charucohdr]
#include <vector>
#include <iostream>
#include "aruco_samples_utility.hpp"

using namespace std;
using namespace cv;

namespace {
const char* about = "Pose estimation using a ChArUco board";
const char* keys  =
        "{w        |       | Number of squares in X direction }"
        "{h        |       | Number of squares in Y direction }"
        "{sl       |       | Square side length (in meters) }"
        "{ml       |       | Marker side length (in meters) }"
        "{d        |       | dictionary: DICT_4X4_50=0, DICT_4X4_100=1, DICT_4X4_250=2,"
        "DICT_4X4_1000=3, DICT_5X5_50=4, DICT_5X5_100=5, DICT_5X5_250=6, DICT_5X5_1000=7, "
        "DICT_6X6_50=8, DICT_6X6_100=9, DICT_6X6_250=10, DICT_6X6_1000=11, DICT_7X7_50=12,"
        "DICT_7X7_100=13, DICT_7X7_250=14, DICT_7X7_1000=15, DICT_ARUCO_ORIGINAL = 16}"
        "{cd       |       | Input file with custom dictionary }"
        "{c        |       | Output file with calibrated camera parameters }"
        "{v        |       | Input from video or image file, if ommited, input comes from camera }"
        "{ci       | 0     | Camera id if input doesnt come from video (-v) }"
        "{dp       |       | File of marker detector parameters }"
        "{rs       |       | Apply refind strategy }";
}


int main(int argc, char *argv[]) {
    CommandLineParser parser(argc, argv, keys);
    parser.about(about);

    if(argc < 6) {
        parser.printMessage();
        return 0;
    }

    //! [charuco_detect_board_full_sample]
    int squaresX = parser.get<int>("w");
    int squaresY = parser.get<int>("h");
    float squareLength = parser.get<float>("sl");
    float markerLength = parser.get<float>("ml");
    bool refine = parser.has("rs");
    int camId = parser.get<int>("ci");

    string video;
    if(parser.has("v")) {
        video = parser.get<string>("v");
    }

    Mat camMatrix, distCoeffs;
    readCameraParamsFromCommandLine(parser, camMatrix, distCoeffs);
    aruco::DetectorParameters detectorParams = readDetectorParamsFromCommandLine(parser);
    aruco::Dictionary dictionary = readDictionatyFromCommandLine(parser);

    if(!parser.check()) {
        parser.printErrors();
        return 0;
    }

    VideoCapture inputVideo;
    int waitTime = 0;
    if(!video.empty()) {
        inputVideo.open(video);
    } else {
        inputVideo.open(camId);
        waitTime = 10;
    }

    float axisLength = 0.5f * ((float)min(squaresX, squaresY) * (squareLength));

    // create charuco board object
    aruco::CharucoBoard charucoBoard(Size(squaresX, squaresY), squareLength, markerLength, dictionary);

    // create charuco detector
    aruco::CharucoParameters charucoParams;
    charucoParams.tryRefineMarkers = refine; // if tryRefineMarkers, refineDetectedMarkers() will be used in detectBoard()
    charucoParams.cameraMatrix = camMatrix; // cameraMatrix can be used in detectBoard()
    charucoParams.distCoeffs = distCoeffs; // distCoeffs can be used in detectBoard()
    aruco::CharucoDetector charucoDetector(charucoBoard, charucoParams, detectorParams);

    double totalTime = 0;
    int totalIterations = 0;

    while(inputVideo.grab()) {
        //! [inputImg]
        Mat image, imageCopy;
        inputVideo.retrieve(image);
        //! [inputImg]

        double tick = (double)getTickCount();

        vector<int> markerIds, charucoIds;
        vector<vector<Point2f> > markerCorners;
        vector<Point2f> charucoCorners;
        Vec3d rvec, tvec;

        //! [interpolateCornersCharuco]
        // detect markers and charuco corners
        charucoDetector.detectBoard(image, charucoCorners, charucoIds, markerCorners, markerIds);
        //! [interpolateCornersCharuco]

        //! [poseCharuco]
        // estimate charuco board pose
        bool validPose = false;
        if(camMatrix.total() != 0 && distCoeffs.total() != 0 && charucoIds.size() >= 4) {
            Mat objPoints, imgPoints;
            charucoBoard.matchImagePoints(charucoCorners, charucoIds, objPoints, imgPoints);
            validPose = solvePnP(objPoints, imgPoints, camMatrix, distCoeffs, rvec, tvec);
        }
        //! [poseCharuco]

        double currentTime = ((double)getTickCount() - tick) / getTickFrequency();
        totalTime += currentTime;
        totalIterations++;
        if(totalIterations % 30 == 0) {
            cout << "Detection Time = " << currentTime * 1000 << " ms "
                 << "(Mean = " << 1000 * totalTime / double(totalIterations) << " ms)" << endl;
        }

        // draw results
        image.copyTo(imageCopy);
        if(markerIds.size() > 0) {
            aruco::drawDetectedMarkers(imageCopy, markerCorners);
        }

        if(charucoIds.size() > 0) {
            //! [drawDetectedCornersCharuco]
            aruco::drawDetectedCornersCharuco(imageCopy, charucoCorners, charucoIds, cv::Scalar(255, 0, 0));
            //! [drawDetectedCornersCharuco]
        }

        if(validPose)
            cv::drawFrameAxes(imageCopy, camMatrix, distCoeffs, rvec, tvec, axisLength);

        imshow("out", imageCopy);
        if(waitKey(waitTime) == 27) break;
    }
    //! [charuco_detect_board_full_sample]
    return 0;
}

6.1 ChAruco板创建

aruco模块提供了cv::aruco::CharucoBoard类,该类代表一个Charuco Board,并继承自cv::aruco::Board类。

该类和其他ChArUco功能的定义在:

#include <opencv2/objdetect/charuco_detector.hpp>
要定义一个 cv::aruco::CharucoBoard,必须提供:
	1、X 和 Y 方向上的棋盘格数量。
	2、方块边长。
	3、标记边长。
	4、标记字典。
	5、所有标记的 ID。

至于 cv::aruco::GridBoard 对象,aruco 模块提供了创建 cv::aruco::CharucoBoard 的方法。可以使用 cv::aruco::CharucoBoard 构造函数轻松地从这些参数创建此对象:

aruco::Dictionary dictionary = readDictionatyFromCommandLine(parser);
cv::aruco::CharucoBoard board(Size(squaresX, squaresY), (float)squareLength, (float)markerLength, dictionary);
// 第一个参数:X和Y方向上棋盘格方块的数量。
// 第二个和第三个参数:分别是棋盘格方块的长度和aruco标记的长度。它们可以以任何单位提供,但要记住,这个板的估计姿态将以相同的单位进行测量(通常使用米)。
// 最后,提供了标记的字典。

每个标记的 ID 默认按升序分配,从 0 开始,就像在 cv::aruco::GridBoard 构造函数中一样。这可以通过访问 board.ids 中的 ids 向量轻松自定义,就像在 cv::aruco::Board 父类中一样。

Mat boardImage;
Size imageSize;
imageSize.width = squaresX * squareLength + 2 * margins;
imageSize.height = squaresY * squareLength + 2 * margins;
board.generateImage(imageSize, boardImage, margins, borderBits);
// 第一个参数是输出图像的像素大小。如果这与棋盘格尺寸不成比例,它将居中显示在图像上。
// 第二个参数是带有 Charuco 棋盘的输出图像。
// 第三个参数是(可选的)像素边距,以确保没有任何标记接触图像边界。
// 最后,标记边框的大小类似于 cv::aruco::generateImageMarker() 函数。默认值为 1。

源码

samples/cpp/tutorial_code/objectDetection/ 中的 create_board_charuco.cpp 包含了一个完整的示例。
现在,create_board_charuco.cpp 通过命令行使用 cv::CommandLineParser 接收输入。对于此文件,示例参数如下:

"_output_path_/chboard.png" -w=5 -h=7 -sl=100 -ml=60 -d=10

在这里插入图片描述

#include <opencv2/highgui.hpp>
#include <opencv2/objdetect/charuco_detector.hpp>
#include <iostream>

namespace {
const char *about = "Create a ChArUco board image";
//! [charuco_detect_board_keys]
const char *keys = "{@outfile |res.png| Output image }"
                   "{w        |  5    | Number of squares in X direction }"
                   "{h        |  7    | Number of squares in Y direction }"
                   "{sl       |  100  | Square side length (in pixels) }"
                   "{ml       |  60   | Marker side length (in pixels) }"
                   "{d        |       | dictionary: DICT_4X4_50=0, DICT_4X4_100=1, DICT_4X4_250=2,"
                   "DICT_4X4_1000=3, DICT_5X5_50=4, DICT_5X5_100=5, DICT_5X5_250=6, DICT_5X5_1000=7, "
                   "DICT_6X6_50=8, DICT_6X6_100=9, DICT_6X6_250=10, DICT_6X6_1000=11, DICT_7X7_50=12,"
                   "DICT_7X7_100=13, DICT_7X7_250=14, DICT_7X7_1000=15, DICT_ARUCO_ORIGINAL = 16}"
                   "{cd       |       | Input file with custom dictionary }"
                   "{m        |       | Margins size (in pixels). Default is (squareLength-markerLength) }"
                   "{bb       | 1     | Number of bits in marker borders }"
                   "{si       | false | show generated image }";
} // namespace
//! [charuco_detect_board_keys]

// 生成 5x7 标定板,每格边长 3.2mm,aruco边长 1.6mm
void createChArucoBoard()
{
    int squaresX     = 5;                           // X方向上棋盘格方块的数量
    int squaresY     = 7;                           // Y方向上棋盘格方块的数量
    int squareLength = 100;                         // 棋盘格方块的长度,单位:像素
    int markerLength = 60;                          // aruco标记的长度
    int margins      = squareLength - markerLength; // 格子和标记之间的边距

    int borderBits = 1;    // 标记边框的大小类似于 cv::aruco::generateImageMarker() 函数,默认值为 1
    bool showImage = true; // 是否显示生成的图像

    std::string pathOutImg = "chboard.png";

    //! [create_charucoBoard]
    cv::aruco::Dictionary dictionary = cv::aruco::getPredefinedDictionary(cv::aruco::DICT_6X6_250);
    cv::aruco::CharucoBoard board(cv::Size(squaresX, squaresY), (float)squareLength, (float)markerLength, dictionary);
    //! [create_charucoBoard]

    // show created board
    //! [generate_charucoBoard]
    cv::Mat boardImage;
    cv::Size imageSize;
    imageSize.width  = squaresX * squareLength + 2 * margins;
    imageSize.height = squaresY * squareLength + 2 * margins;
    board.generateImage(imageSize, boardImage, margins, borderBits);
    //! [generate_charucoBoard]

    if (showImage) {
        cv::imshow("board", boardImage);
        cv::waitKey(0);
    }

    if (pathOutImg != "")
        cv::imwrite(pathOutImg, boardImage);
}

在这里插入图片描述

6.2 ChArUco板检测

当你检测到一个ChArUco板时,实际上是在检测该板上的每个棋盘角点。
ChArUco板上的每个角点都有一个唯一的标识符(id)分配。这些id从0到板上的总角点数。ChArUco板检测的步骤可以分解为以下步骤:

  • 1.输入图像
Mat image, imageCopy;
inputVideo.retrieve(image);

原始图像中要检测标记。该图像是在ChArUco角点上执行亚像素精炼所必需的。

  • 2.读取相机标定参数(仅用于带有相机标定的检测)
if(parser.has("c")) {
    bool readOk = readCameraParameters(parser.get<std::string>("c"), camMatrix, distCoeffs);
    if(!readOk) {
        throw std::runtime_error("Invalid camera file\n");
    }
}

// readCameraParameters的参数为:
// 第一个参数是相机内参矩阵和畸变系数的路径。
// 第二和第三个参数分别是cameraMatrix和distCoeffs。
// return
// 此函数将这些参数作为输入,并返回一个布尔值,表示相机标定参数是否有效。进行未标定的charuco角点检测时,此步骤不是必需的。
  • 3.检测标记和从标记插值查看角点
    ChArUco角的检测基于先前检测到的标记。因此,首先检测标记,然后从标记中插值出ChArUco角。检测ChArUco角的方法是cv::aruco::CharucoDetector::detectBoard()
// detect markers and charuco corners
charucoDetector.detectBoard(image, charucoCorners, charucoIds, markerCorners, markerIds);

// detectBoard的参数是:
// image - 输入图像。
// charucoCorners - 输出检测到的角点的图像位置列表。
// charucoIds - 输出charucoCorners中每个检测到的角点的ID。
// markerCorners - 检测到的标记角点的输入/输出向量。
// markerIds - 检测到的标记的标识符的输入/输出向量。

如果 markerCorners 和 markerIds 为空,函数将检测 aruco 标记和 ID。

如果提供了标定参数,ChArUco 角点将通过首先从 ArUco 标记估计一个粗略的姿态,然后将 ChArUco 角点重新投影回图像来进行插值。

另一方面,如果不提供标定参数,ChArUco 角点将通过计算 ChArUco 平面与 ChArUco 图像投影之间的对应单应性来进行插值。

使用单应性的主要问题是插值对图像失真更加敏感。实际上,单应性仅使用每个 ChArUco 角点最近的标记进行处理,以减少失真的影响。

在检测 ChArUco 板的标记时,尤其是在使用单应性时,建议禁用标记的角点细化。这是因为,由于棋盘格方块的相近,亚像素处理可能会导致角点位置的重要偏差,这些偏差会传递到 ChArUco 角点插值中,从而产生不良结果。

PS:为了避免偏差,棋盘格与Aruco标记之间的间距应大于一个标记模块的70%
example:假设棋盘格边长100,aruco标记60,则边距设置为 60 x 0.7 = 42,边距设为 100 - 60 = 40

此外,只有当两个相邻标记都被找到时,这些角点才会被返回。如果任何一个相邻标记未被检测到,通常意味着该区域存在遮挡或图像质量不佳。在任何情况下,最好不考虑那个角点,因为我们希望确保插值的ChArUco角点非常准确。
在插值ChArUco角点之后,会执行亚像素精化。
一旦我们插值了ChArUco角点,我们可能希望绘制它们以检查其检测是否正确。这可以轻松地使用cv::aruco::drawDetectedCornersCharuco()函数完成:

aruco::drawDetectedCornersCharuco(imageCopy, charucoCorners, charucoIds, cv::Scalar(255, 0, 0));

// imageCopy是将要绘制角点的图像(通常是检测到角点的相同图像)。  
// outputImage将是带有绘制角点的inputImage的克隆。  
// charucoCorners和charucoIds是从cv::aruco::CharucoDetector::detectBoard()函数检测到的Charuco角点。  
// 最后一个参数是我们想要绘制角点的(可选)颜色,类型为cv::Scalar。

源码

在这里插入图片描述
在这里插入图片描述

完整的工作示例包含在 samples/cpp/tutorial_code/objectDetection/ 中的 detect_board_charuco.cpp 文件内。

现在,samples detect_board_charuco.cpp 通过 cv::CommandLineParser 从命令行获取输入。对于该文件,示例参数是:

-w=5 -h=7 -sl=0.04 -ml=0.02 -d=10 -v=/path_to_opencv/opencv/doc/tutorials/objdetect/charuco_detection/images/choriginal.jpg
  • 源代码:detect_board_charuco.cpp
#include <opencv2/highgui.hpp>
//! [charucohdr]
#include <opencv2/objdetect/charuco_detector.hpp>
//! [charucohdr]
#include <vector>
#include <iostream>
#include "aruco_samples_utility.hpp"

using namespace std;
using namespace cv;

namespace {
const char* about = "Pose estimation using a ChArUco board";
const char* keys  =
        "{w        |       | Number of squares in X direction }"
        "{h        |       | Number of squares in Y direction }"
        "{sl       |       | Square side length (in meters) }"
        "{ml       |       | Marker side length (in meters) }"
        "{d        |       | dictionary: DICT_4X4_50=0, DICT_4X4_100=1, DICT_4X4_250=2,"
        "DICT_4X4_1000=3, DICT_5X5_50=4, DICT_5X5_100=5, DICT_5X5_250=6, DICT_5X5_1000=7, "
        "DICT_6X6_50=8, DICT_6X6_100=9, DICT_6X6_250=10, DICT_6X6_1000=11, DICT_7X7_50=12,"
        "DICT_7X7_100=13, DICT_7X7_250=14, DICT_7X7_1000=15, DICT_ARUCO_ORIGINAL = 16}"
        "{cd       |       | Input file with custom dictionary }"
        "{c        |       | Output file with calibrated camera parameters }"
        "{v        |       | Input from video or image file, if ommited, input comes from camera }"
        "{ci       | 0     | Camera id if input doesnt come from video (-v) }"
        "{dp       |       | File of marker detector parameters }"
        "{rs       |       | Apply refind strategy }";
}


int main(int argc, char *argv[]) {
    CommandLineParser parser(argc, argv, keys);
    parser.about(about);

    if(argc < 6) {
        parser.printMessage();
        return 0;
    }

    //! [charuco_detect_board_full_sample]
    int squaresX = parser.get<int>("w");
    int squaresY = parser.get<int>("h");
    float squareLength = parser.get<float>("sl");
    float markerLength = parser.get<float>("ml");
    bool refine = parser.has("rs");
    int camId = parser.get<int>("ci");

    string video;
    if(parser.has("v")) {
        video = parser.get<string>("v");
    }

    Mat camMatrix, distCoeffs;
    readCameraParamsFromCommandLine(parser, camMatrix, distCoeffs);
    aruco::DetectorParameters detectorParams = readDetectorParamsFromCommandLine(parser);
    aruco::Dictionary dictionary = readDictionatyFromCommandLine(parser);

    if(!parser.check()) {
        parser.printErrors();
        return 0;
    }

    VideoCapture inputVideo;
    int waitTime = 0;
    if(!video.empty()) {
        inputVideo.open(video);
    } else {
        inputVideo.open(camId);
        waitTime = 10;
    }

    float axisLength = 0.5f * ((float)min(squaresX, squaresY) * (squareLength));

    // create charuco board object
    aruco::CharucoBoard charucoBoard(Size(squaresX, squaresY), squareLength, markerLength, dictionary);

    // create charuco detector
    aruco::CharucoParameters charucoParams;
    charucoParams.tryRefineMarkers = refine; // if tryRefineMarkers, refineDetectedMarkers() will be used in detectBoard()
    charucoParams.cameraMatrix = camMatrix; // cameraMatrix can be used in detectBoard()
    charucoParams.distCoeffs = distCoeffs; // distCoeffs can be used in detectBoard()
    aruco::CharucoDetector charucoDetector(charucoBoard, charucoParams, detectorParams);

    double totalTime = 0;
    int totalIterations = 0;

    while(inputVideo.grab()) {
        //! [inputImg]
        Mat image, imageCopy;
        inputVideo.retrieve(image);
        //! [inputImg]

        double tick = (double)getTickCount();

        vector<int> markerIds, charucoIds;
        vector<vector<Point2f> > markerCorners;
        vector<Point2f> charucoCorners;
        Vec3d rvec, tvec;

        //! [interpolateCornersCharuco]
        // detect markers and charuco corners
        charucoDetector.detectBoard(image, charucoCorners, charucoIds, markerCorners, markerIds);
        //! [interpolateCornersCharuco]

        //! [poseCharuco]
        // estimate charuco board pose
        bool validPose = false;
        if(camMatrix.total() != 0 && distCoeffs.total() != 0 && charucoIds.size() >= 4) {
            Mat objPoints, imgPoints;
            charucoBoard.matchImagePoints(charucoCorners, charucoIds, objPoints, imgPoints);
            validPose = solvePnP(objPoints, imgPoints, camMatrix, distCoeffs, rvec, tvec);
        }
        //! [poseCharuco]

        double currentTime = ((double)getTickCount() - tick) / getTickFrequency();
        totalTime += currentTime;
        totalIterations++;
        if(totalIterations % 30 == 0) {
            cout << "Detection Time = " << currentTime * 1000 << " ms "
                 << "(Mean = " << 1000 * totalTime / double(totalIterations) << " ms)" << endl;
        }

        // draw results
        image.copyTo(imageCopy);
        if(markerIds.size() > 0) {
            aruco::drawDetectedMarkers(imageCopy, markerCorners);
        }

        if(charucoIds.size() > 0) {
            //! [drawDetectedCornersCharuco]
            aruco::drawDetectedCornersCharuco(imageCopy, charucoCorners, charucoIds, cv::Scalar(255, 0, 0));
            //! [drawDetectedCornersCharuco]
        }

        if(validPose)
            cv::drawFrameAxes(imageCopy, camMatrix, distCoeffs, rvec, tvec, axisLength);

        imshow("out", imageCopy);
        if(waitKey(waitTime) == 27) break;
    }
    //! [charuco_detect_board_full_sample]
    return 0;
}
  • 简化后
void detectCharucoPose()
{
    //! [charuco_detect_board_full_sample]
    int squaresX       = 5;     // X方向上棋盘格方块的数量
    int squaresY       = 7;     // Y方向上棋盘格方块的数量
    float squareLength = 0.04;  // 棋盘格方块的长度,单位:m
    float markerLength = 0.02;  // aruco标记的长度,单位:m
    bool refine        = false; // try to use refine board, default false

    cv::Mat camMatrix  = (cv::Mat_<double>(3, 3) << 643.154541015625,
                         0,
                         639.794189453125,
                         0,
                         643.154541015625,
                         357.18597412109375,
                         0,
                         0,
                         1);
    cv::Mat distCoeffs = (cv::Mat_<double>(5, 1) << 0.0, 0.0, 0.0, 0.0, 0.0);

    cv::aruco::DetectorParameters detectorParams;
    cv::aruco::Dictionary dictionary = cv::aruco::getPredefinedDictionary(cv::aruco::DICT_6X6_250);

    float axisLength = 0.5f * ((float)cv::min(squaresX, squaresY) * (squareLength));

    // create charuco board object
    cv::aruco::CharucoBoard charucoBoard(cv::Size(squaresX, squaresY), squareLength, markerLength, dictionary);

    // create charuco detector
    cv::aruco::CharucoParameters charucoParams;
    charucoParams.tryRefineMarkers =
        refine; // if tryRefineMarkers, refineDetectedMarkers() will be used in detectBoard()
    charucoParams.cameraMatrix = camMatrix;  // cameraMatrix can be used in detectBoard()
    charucoParams.distCoeffs   = distCoeffs; // distCoeffs can be used in detectBoard()
    cv::aruco::CharucoDetector charucoDetector(charucoBoard, charucoParams, detectorParams);

    double totalTime    = 0;
    int totalIterations = 0;

    //! [inputImg]
    cv::Mat image = cv::imread("choriginal.jpg");
    cv::Mat imageCopy;

    std::vector<int> markerIds, charucoIds;
    std::vector<std::vector<cv::Point2f>> markerCorners;
    std::vector<cv::Point2f> charucoCorners;
    cv::Vec3d rvec, tvec;

    //! [interpolateCornersCharuco]
    // detect markers and charuco corners
    charucoDetector.detectBoard(image, charucoCorners, charucoIds, markerCorners, markerIds);
    //! [interpolateCornersCharuco]

    //! [poseCharuco]
    // estimate charuco board pose
    bool validPose = false;
    if (camMatrix.total() != 0 && distCoeffs.total() != 0 && charucoIds.size() >= 4) {
        cv::Mat objPoints, imgPoints;
        charucoBoard.matchImagePoints(charucoCorners, charucoIds, objPoints, imgPoints);
        validPose = solvePnP(objPoints, imgPoints, camMatrix, distCoeffs, rvec, tvec);
    }
    //! [poseCharuco]

    // draw results
    image.copyTo(imageCopy);
    if (markerIds.size() > 0) {
        cv::aruco::drawDetectedMarkers(imageCopy, markerCorners);
    }

    if (charucoIds.size() > 0) {
        //! [drawDetectedCornersCharuco]
        cv::aruco::drawDetectedCornersCharuco(imageCopy, charucoCorners, charucoIds, cv::Scalar(255, 0, 0));
        //! [drawDetectedCornersCharuco]
    }

    if (validPose)
        cv::drawFrameAxes(imageCopy, camMatrix, distCoeffs, rvec, tvec, axisLength);

    cv::imshow("out", imageCopy);
    cv::waitKey(0);
}

输出和示例代码一致
在这里插入图片描述

6.3 ChArUco 姿态估计

ChArUco 棋盘的最终目标是为了高精度校准或姿态估计而非常准确地找到角点。

aruco 模块提供了一个函数,可以轻松地执行 ChArUco 姿态估计。与 cv::aruco::GridBoard 相似,cv::aruco::CharucoBoard 的坐标系统放置在棋盘平面上,Z 轴指向内部,并以棋盘的左下角为中心

在OpenCV 4.6.0之后,板的坐标系发生了一个不兼容的变化,现在坐标系被放置在板的平面上,Z轴指向平面(之前轴是指向平面外)。按顺时针顺序排列的objPoints对应于Z轴指向平面。按逆时针顺序排列的objPoints对应于Z轴指向平面外。
https://github.com/opencv/opencv_contrib/pull/3174

要对 Charuco 板执行姿态估计,应该使用 cv::aruco::CharucoBoard::matchImagePoints()cv::solvePnP():

// estimate charuco board pose
bool validPose = false;
if(camMatrix.total() != 0 && distCoeffs.total() != 0 && charucoIds.size() >= 4) {
    Mat objPoints, imgPoints;
    charucoBoard.matchImagePoints(charucoCorners, charucoIds, objPoints, imgPoints);
    validPose = solvePnP(objPoints, imgPoints, camMatrix, distCoeffs, rvec, tvec);
}
// charucoCorners和charucoIds参数是来自cv::aruco::CharucoDetector::detectBoard()函数检测到的charuco角点。
// cameraMatrix和distCoeffs是进行姿态估计所需的相机标定参数。最后,rvec和tvec参数是Charuco板的输出姿态。
// cv::solvePnP()在姿态正确估计时返回true,否则返回false。失败的主要原因是用于姿态估计的角点不足,或者它们在同一条线上。

轴可以使用 cv::drawFrameAxes() 方法绘制,以检查姿态是否正确估计。结果为:(X:红色,Y:绿色,Z:蓝色)
在这里插入图片描述
samples/cpp/tutorial_code/objectDetection/ 目录中包含了完整的 detect_board_charuco.cpp 示例。
现在的 samples detect_board_charuco.cpp 通过命令行使用 cv::CommandLineParser 接收输入。对于此文件,示例参数如下:

-w=5 -h=7 -sl=0.04 -ml=0.02 -d=10
-v=/path_to_opencv/opencv/doc/tutorials/objdetect/charuco_detection/images/choriginal.jpg
-c=/path_to_opencv/opencv/samples/cpp/tutorial_code/objectDetection/tutorial_camera_charuco.yml

https://github.com/fengzhenHIT/OpenCV-contrib-module-Chinese-Tutorials/blob/master/chapter%202/%E4%BD%BF%E7%94%A8ArUco%E5%92%8CChArUco%E8%BF%9B%E8%A1%8C%E7%9B%B8%E6%9C%BA%E6%A0%87%E5%AE%9A.md

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

boss-dog

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值