ArUco库的学习与使用(二)

ArUco库的学习与使用(二)



前言

OpenCV以及ArUco库安装完成后,打开aruco.sln文件,首先开始学习aruco_simple.cpp文件中相关代码。


一、包含头文件

代码如下:

#include "aruco.h"
#include <iostream>
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/imgproc/imgproc.hpp>
#include <string>
#include <stdexcept>

using namespace cv;
using namespace std;
using namespace aruco;
  1. #include <stdexcept>

C++ 标准库的头文件之一,其中包含了标准异常类 std::exception 的定义,以及一些其他相关的异常类型。当在 C++ 程序中包含这个头文件时,就可以使用 C++ 标准库提供的异常处理功能,比如抛出和捕获异常。通常情况下,当想要以一种结构化和组织良好的方式处理程序中的异常情况时,就会使用这个头文件。

二、使用 CmdLineParser 类解析命令行参数

代码如下:

class CmdLineParser
{
	int argc;
	char **argv;
public:
	/*
	类CmdLineParser的构造函数,用于初始化类的成员变量
	int _argc 表示命令行参数个数
	char **_argv 表示命令行参数的字符串数组
	argc(_argc),argv(_argv) 构造函数初始化列表,用于在对象创建时对成员变量初始化
	*/
	CmdLineParser(int _argc, char **_argv) : argc(_argc), argv(_argv) {}
	/*
	重载的运算符函数含有关键字operator
	返回类型 operator 运算符 (参数列表)
	{
	   函数体
	}
	遍历命令行参数数组查找参数param是否存在 -存在 将其索引赋值给idx -不存在 idx保持默认值-1
	*/
	bool operator[](string param)
	{
		int idx = -1; //初始化变量idx为-1
		for (int i = 0; i < argc && idx == -1; i++) //遍历命令行参数数组,查找参数位置
			if (string(argv[i]) == param)
				idx = i; //找到参数param,索引赋值给idx
		return (idx != -1); //
	}

	/*
	用于获取特定参数param的取值,可以指定默认值defvalue
	*/
	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]);
	}
};
  1. 相关知识点已在代码注释中给出

三、调整图像大小

代码如下:

cv::Mat __resize(const cv::Mat& in, int width)
{
	if (in.size().width <= width) //检查输入图像宽度是否小于等于目标宽度
		return in;
	float yf = float(width) / float(in.size().width); //计算调整比例:目标宽度与输入图像宽度比值
	cv::Mat im2;
	cv::resize(in, im2, cv::Size(width, static_cast<int>(in.size().height*yf)));
	return im2;
}

知识点总结

  1. cv::resize():用于调整图像的大小。它可以将图像放大或缩小到指定的尺寸。
    语法:cv::resize(input, output, size, fx, fy, interpolation)input是输入图像,output 是输出图像,size 是指定的目标大小,fxfy 是沿水平和垂直轴的缩放比例,interpolation 是插值方法

  2. cv::Mat::size():用于获取矩阵或图像的大小,返回一个cv::Size对象,其中包括图像的宽度和高度信息

  3. static_cast<type> ():用于执行显式类型转换,将一种数据类型转换为另一种数据类型

四、main主函数

分段学习

  1. try语句块:异常处理部分使用try语句块处理异常–以关键字try开始,并以一个或多个catch子句结束。try语句块中代码抛出的异常通常会被某个catch子句处理
  2. 命令行参数解析器:解析用户在命令行中输入的参数

代码如下:

         /*
		命令行参数解析器,用于解析用户在命令行中输入的参数
		*/
		CmdLineParser cml(argc, argv);
		if (argc == 1 || cml["-h"]) //判断命令行参数的个数是否为1或是否存在"-h"参数
		{
			cerr << "Usage:(in_image|video.avi) [-c cameraParams.yml] [-s markerSize] [-d <dictionary>:ALL_DICTS default] [-f arucoConfig.yml]" << endl; //输出使用说明,列出命令行参数用法
			cerr << "\tDictories"; //输出用户可选择字典类型
			for (auto dict : aruco::Dictionary::getDicTypes())
				cerr << dict << " ";
			cerr << endl;
			cerr << "\t Instead of these, you can directly indicate the path to a file with your own generated"
				"dictionary"
				<< endl;
			cout << "Example to work with apriltags dictionary : video.avi -d TAG36h11" << endl;
			return 0;
		}

(1) cerr:一个ostream对象,关联到标准错误,通常写入到与标准输出相同的设备。默认情况下,写入cerr的数据是不缓冲的。cerr通常用于输出错误信息或其他不属于程序正常逻辑的输出内容

  1. 读取输入的图像或视频文件

代码如下

        /*
		读取输入的图像或视频文件
		*/
		aruco::CameraParameters CamParam; //定义名为CameraParameters的相机参数对象
		
		cv::Mat InImage;
		VideoCapture vreader(argv[1]);
		if (vreader.isOpened()) //判断是否成功打开视频文件
			vreader >> InImage; //将下一帧图像读取到InImage中
		else
			throw std::runtime_error("Could not open input"); //打开视频文件失败则抛出异常

(1)cv::Mat: OpenCV 中的图像矩阵类,用于存储图像数据。

(2)VideoCapture: OpenCV 中用于读取视频的类,它可以打开一个视频文件并读取帧。

(3).isOpened():isOpened() 是 VideoCapture 类的一个函数,用于判断视频文件是否成功打开。

(4)vreader >> InImage:使用 >> 运算符从 VideoCapture 对象中读取下一帧图像,并将图像数据存储到 InImage 的 cv::Mat 对象中。

(5)throw std::runtime_error():throw 语句用于抛出异常,std::runtime_error 是 C++ 标准库中定义的一个异常类,可以通过传递错误消息来创建异常对象。使用 throw 抛出的异常可以被 try-catch 块捕获和处理。

注意:上述操作只会读取视频或者摄像头中的第一帧图像数据。如果希望连续读取视频的多帧图像,你可以将 vreader >> InImage; 这行代码放在循环中,这样可以不断地读取视频的下一帧图像,直到视频结束或者达到你所需的帧数。

  1. 读取命令行中该相机参数文件的内容

代码如下

		// read camera parameters if specifed
		if (cml["-c"])
			Camparam.readFromXMLFile(cml("-c"));

将参数值加载到 CamParam 对象中,以便在后续的相机操作中使用。关于CameraParameters类以及类内的readFromXMLFile()成员函数在之后再做学习,暂时不用

  1. 根据命令行参数设置标记大小

代码如下

        // read marker size if specified (default value -1)
		float MarkerSize = std::stof(cml("-s", "-1")); //获取-s参数的值并转换为浮点数类型.默认值为-1
		// Create the detector
		MarkerDetector MDetector;
		if (cml["-f"])
		{
			// uses a configuration file. You can create it from aruco_test application
			MDetector.loadParamsFromFile(cml("-f"));
		}
		else
		{
			// Set the dictionary you want to work with, if you included option -d command line
			// By default,all valid dictionaries are examined
			if (cml["-d"])
				MDetector.setDictionary(cml("-d"), 0.f);
		}

(1)std::stof : C++ 中用于将字符串转换为浮点数的函数
语法:float std::stof( const std::string& str, std::size_t* pos = 0 );
(2)MarkerDetector类:重点!!!!!!!!!!!!!
a. MDetector.loadParamsFromFile():见五、3.
b. MDetector.setDictionary():见五、4.

6.对检测到的标记进行处理和绘制

代码如下

vector<Marker> Markers = MDetector.detect(InImage, CamParam, MarkerSize);

        // for each marker, draw info and its boundaries in the image
        for (unsigned int i = 0; i < Markers.size(); i++)
        {
            cout << Markers[i] << endl;
            Markers[i].draw(InImage, Scalar(0, 0, 255), 2);
        }
        // draw a 3d cube in each marker if there is 3d info
        if (CamParam.isValid() && MarkerSize != -1)
            for (unsigned int i = 0; i < Markers.size(); i++)
            {
                if(Markers[i].id==229 || Markers[i].id==161)
                    cout<< "Camera Location= "<<Markers[i].id<<" "<<CamParam.getCameraLocation(Markers[i].Rvec,Markers[i].Tvec)<<endl;
                CvDrawingUtils::draw3dAxis(InImage, Markers[i], CamParam);
              //  CvDrawingUtils::draw3dCube(InImage, Markers[i], CamParam);
            }

这段代码对检测到的标记进行了一些处理和绘制操作
(1)vector<Marker> Markers = MDetector.detect(InImage, CamParam, MarkerSize)
这行代码调用了 MDetector 对象的 detect 方法(见五、5.),使用输入图像 InImage、相机参数 CamParam 和标记大小 MarkerSize 来检测标记,并将结果存储在名为 Markers 的 std::vector 中。

(2)for (unsigned int i = 0; i < Markers.size(); i++) { ... }
这个循环遍历了存储在 Markers 中的每个标记对象,并对每个标记执行以下操作:

a. 输出标记信息到控制台,即使用 cout 打印标记对象的信息
b. 在输入图像 InImage 中绘制检测到的标记的边界,并用红色线条(Scalar(0, 0, 255))绘制

(3)if (CamParam.isValid() && MarkerSize != -1) { ... }
这段代码检测了相机参数的有效性和标记大小的值,然后对每个标记执行以下操作:

如果标记的 id 是 229 或 161,则输出标记的相机位置信息到控制台
在输入图像 InImage 中绘制每个标记的三维坐标轴,使用相机参数 CamParam 来进行绘制

  1. 显示图像

代码如下

// show input with augmented information
		cv::namedWindow("in", 1); // 窗口大小可被用户调整
		cv::imshow("in", __resize(InImage, 1280)); // 显示经过调整后的输入图像
		while (char(cv::waitKey(0) != 27))
			; // wait for esc to be pressed
  1. 异常处理块

代码如下

 catch (std::exception& ex)

    {
        cout << "Exception :" << ex.what() << endl;
    }

(1)try { ... } catch (std::exception& ex) { ... }
这个代码块用于捕获可能在 try 代码块中发生的 std::exception 类型的异常,并将其作为引用传递给名为 ex 的异常对象。

(2)cout << "Exception :" << ex.what() << endl
在发生异常时,将异常对象的 what() 方法返回的异常描述信息输出到控制台。ex.what() 返回一个 const char* 类型的指针,指向包含异常原因的字符串。

五、MarkerDetector类

  1. 定义MarkerDetector类

代码如下

class ARUCO_EXPORT MarkerDetector {};

ARUCO_EXPORT 是一个宏,宏的作用是根据当前的开发平台,动态地定义类的导出规则,以确保在使用 ArUco 库时,MarkerDetector 类能够正确地被外部代码访问和使用。在不同的平台上,库的使用方式可能会有所不同。在某些平台上,需要使用 __declspec(dllexport) 来显式导出类的符号。而在其他平台上,可能会使用其他的导出规则。–上述内容不知道是否正确

  1. 定义枚举类型ThresMethod以及友元MarkerDetector_Impl

代码如下

enum ThresMethod: int{THRES_ADAPTIVE=0,THRES_AUTO_FIXED=1 };
friend class MarkerDetector_Impl;

上述代码定义了一个枚举类型 ThresMethod,其基础类型为 int。该枚举类型包含两个枚举常量:

THRES_ADAPTIVE 被赋值为 0
THRES_AUTO_FIXED 被赋值为 1

声明了一个类 MarkerDetector_Impl 为 MarkerDetector 类的友元。通过声明为友元,MarkerDetector_Impl 类可以访问和使用 MarkerDetector 类的私有成员,即使这些成员在普通情况下是不可访问的。

关于枚举的一些知识点:
枚举是一种用于定义符号常量集合的数据类型。在许多编程语言中,枚举类型可以帮助程序员更清晰地定义和使用一组相关的常量,提高了代码的可读性和可维护性。以下是关于枚举的一些基本知识点:

(1)枚举的定义:枚举类型定义了一组有限的命名常量,这些常量也被称为枚举成员。在大多数编程语言中,枚举类型可以使用 enum 关键字来定义。

(2)枚举成员:枚举类型中的每个常量都被称为枚举成员,它们代表了一些具体的值。例如,在一个表示颜色的枚举类型中,可能包含红色、绿色和蓝色等枚举成员。

(3)基础类型:枚举类型可以有一个关联的基础类型,代表枚举成员的存储类型。在某些编程语言中,枚举成员可以是整数、字符或其他数据类型。

(4)枚举的使用:在程序中,可以使用枚举成员来代替硬编码的整数或其他值,以提高代码的可读性。例如,可以使用 Color.Red 来表示红色,而不是使用数字 0。

(5)枚举的优点:枚举类型可以使代码更具可读性,减少了硬编码值的使用,有助于提高代码的可维护性和可理解性。

(6)枚举的限制:在一些编程语言中,枚举类型可能有一些限制,例如枚举成员的命名规则、基础类型等方面的限制。

  1. 成员函数:void loadParamsFromFile(const std::string &path)

代码如下

void MarkerDetector::loadParamsFromFile(const std::string &path){
    _impl->loadParamsFromFile(path);
}
void MarkerDetector_Impl::loadParamsFromFile(const std::string &path){
    cv::FileStorage fs(path, cv::FileStorage::READ);
    if(!fs.isOpened())throw std::runtime_error("Could not open "+path);
    _params.load(fs);
    setDictionary(_params.dictionary,_params.error_correction_rate);
}

名为 loadParamsFromFile 的成员函数的定义,属于名为 MarkerDetector_Impl 的类。该函数接受一个常量引用类型的 std::string 参数 path,用于指定文件路径。在函数体内部,它首先使用 OpenCV 的 FileStorage 类打开指定路径的文件,然后加载其中的参数并对其进行处理。

具体来说,代码首先尝试打开指定路径的文件,如果文件打开失败,则抛出一个 std::runtime_error 异常。如果文件成功打开,则将参数加载到 _params 中,然后使用加载的参数设置对象的字典和纠错率。
(1)cv::FileStorage:这是 OpenCV 中用于读写文件的类。它通常用于从文件中读取配置参数、模型权重等数据。

  1. 成员函数: void setDictionary(std::string dict_type, float error_correction_rate = 0)

代码如下

void MarkerDetector::setDictionary(string dict_type, float error_correction_rate)
{
    _impl->setDictionary(dict_type,error_correction_rate);

}
void MarkerDetector_Impl::setDictionary(string dict_type, float error_correction_rate)
{
		auto _to_string=[](float i){
			std::stringstream str;str<<i;return str.str();
			};
        _params.dictionary=dict_type;
    markerIdDetector = MarkerLabeler::create(dict_type, _to_string(error_correction_rate));
    _params.error_correction_rate=error_correction_rate;
}

该函数接受一个 std::string 类型的参数 dict_type 和一个float类型的参数 error_correction_rate,用于设置对象的字典和错误纠正率。在函数体内部,首先定义了一个 lambda 表达式_to_string,用于将浮点数转换为字符串。然后,将传入的 dict_type 赋值给 _params.dictionary,用于存储字典类型。使用 MarkerLabeler::create 静态函数创建MarkerLabeler 对象,并将 dict_type 和转换后的字符串形式的error_correction_rate 作为参数传递给它,从而创建相应字典类型的标记检测器对象,并将其存储在 markerIdDetector 变量中。最后,将传入的 error_correction_rate 赋值给 _params.error_correction_rate,用于存储错误纠正率。
(1)std::stringstream 是 C++ 标准库中的一个类,它是基于字符串的流,用于在内存中读写字符串。它可以像 std::cinstd::cout 一样用于输入和输出,但是数据并不是来自于标准输入或输出,而是从字符串缓冲区中读取或写入。

通过 std::stringstream,我们可以将数据从其他类型(如整数、浮点数、字符串等)转换为字符串形式,或者将字符串解析为其他类型。它的用法类似于文件流 std::ifstreamstd::ofstream

以下是 std::stringstream 常用的用法示例:

/*将其他类型转换为字符串*/
int main() {
  std::stringstream ss;
  int num = 123;
  ss << num;  // 将整数转换为字符串
  std::string str = ss.str();  // 获取转换后的字符串
  return 0;
}
/*将字符串解析为其他类型*/
int main() {
  std::stringstream ss("123.45");
  float num;
  ss >> num;  // 将字符串解析为浮点数
  std::cout << num << std::endl;  // 输出解析结果
  return 0;
}
  1. 成员函数:void MarkerDetector_Impl::detect(const cv::Mat& input, std::vector<Marker>& detectedMarkers, CameraParameters camParams,float markerSizeMeters, bool setYPerpendicular)

代码如下

std::vector<aruco::Marker> MarkerDetector::detect(const cv::Mat& input, const CameraParameters& camParams,
                                                  float markerSizeMeters,
                                                  bool setYPerperdicular)
{
    return _impl->detect(input,camParams,markerSizeMeters,setYPerperdicular);
}
std::vector<aruco::Marker> MarkerDetector_Impl::detect(const cv::Mat& input, const CameraParameters& camParams,
                                                  float markerSizeMeters,
                                                  bool setYPerperdicular)
{
    std::vector<Marker> detectedMarkers;
    detect(input, detectedMarkers, camParams, markerSizeMeters, setYPerperdicular);
    return detectedMarkers;
}
void MarkerDetector_Impl::detect(const cv::Mat& input, std::vector<Marker>& detectedMarkers, CameraParameters camParams,
                            float markerSizeMeters, bool setYPerpendicular)
{
    if (camParams.CamSize != input.size() && camParams.isValid() && markerSizeMeters > 0)
    {
        // must resize camera parameters if we want to compute properly marker poses
        CameraParameters cp_aux = camParams;
        cp_aux.resize(input.size());
        detect(input, detectedMarkers, cp_aux.CameraMatrix, cp_aux.Distorsion, markerSizeMeters, setYPerpendicular);
    }
    else
    {
        detect(input, detectedMarkers, camParams.CameraMatrix, camParams.Distorsion, markerSizeMeters,setYPerpendicular);
    }
}
void MarkerDetector_Impl::detect(const cv::Mat& input, vector<Marker>& detectedMarkers, Mat camMatrix, Mat distCoeff,
                            float markerSizeMeters, bool setYPerpendicular)

函数体里面的内容太多了

六、Marker类

代码如下

class ARUCO_EXPORT Marker : public std::vector<cv::Point2f>
    {
    public:
        // id of  the marker
        int id;
        // size of the markers sides in meters
        float ssize;
        // matrices of rotation and translation respect to the camera
        cv::Mat Rvec, Tvec;
        //additional info about the dictionary
        std::string dict_info;
        //points of the contour
        vector<cv::Point> contourPoints;

        /**
         */
        Marker();
        /**
         */
        Marker(int id);
        /**
         */
        Marker(const Marker& M);
        /**
         */
        Marker(const std::vector<cv::Point2f>& corners, int _id = -1);
        /**
         */
        ~Marker()
        {
        }
        /**Indicates if this object is valid
         */
        bool isValid() const
        {
            return id != -1 && size() == 4;
        }

        bool isPoseValid()const{return !Rvec.empty() && !Tvec.empty();}
        /**Draws this marker in the input image
         */
        void draw(cv::Mat& in, cv::Scalar color=cv::Scalar(0,0,255), int lineWidth = -1, bool writeId = true,bool writeInfo=false) const;

        /**Calculates the extrinsics (Rvec and Tvec) of the marker with respect to the camera
         * @param markerSize size of the marker side expressed in meters
         * @param CP parmeters of the camera
         * @param setYPerpendicular If set the Y axis will be perpendicular to the surface. Otherwise, it will be the Z
         * axis
         */
        void calculateExtrinsics(float markerSize, const CameraParameters& CP,
                                 bool setYPerpendicular = true);
        /**Calculates the extrinsics (Rvec and Tvec) of the marker with respect to the camera
         * @param markerSize size of the marker side expressed in meters
         * @param CameraMatrix matrix with camera parameters (fx,fy,cx,cy)
         * @param Distorsion matrix with distorsion parameters (k1,k2,p1,p2)
         * @param setYPerpendicular If set the Y axis will be perpendicular to the surface. Otherwise, it will be the Z
         * axis
         */
        void calculateExtrinsics(float markerSize, cv::Mat CameraMatrix, cv::Mat Distorsion = cv::Mat(),
                                 bool setYPerpendicular = true);

        /**Given the extrinsic camera parameters returns the GL_MODELVIEW matrix for opengl.
         * Setting this matrix, the reference coordinate system will be set in this marker
         */
        void glGetModelViewMatrix(double modelview_matrix[16]);

        /**
         * Returns position vector and orientation quaternion for an Ogre scene node or entity.
         * 	Use:
         * ...
         * Ogre::Vector3 ogrePos (position[0], position[1], position[2]);
         * Ogre::Quaternion  ogreOrient (orientation[0], orientation[1], orientation[2], orientation[3]);
         * mySceneNode->setPosition( ogrePos  );
         * mySceneNode->setOrientation( ogreOrient  );
         * ...
         */
        void OgreGetPoseParameters(double position[3], double orientation[4]);

        /**Returns the centroid of the marker
            */
        cv::Point2f getCenter() const;
        /**Returns the perimeter of the marker
         */
        float getPerimeter() const;
        /**Returns the area
         */
        float getArea() const;
        /**Returns radius of enclosing circle
         */
        float getRadius()const;
        /**compares ids
         */
        bool operator==(const Marker& m) const
        {
            return m.id == id;
        }

        void copyTo(Marker &m) const;
        /**compares ids
         */
        Marker & operator=(const Marker& m) ;

        /**
         */
        friend bool operator<(const Marker& M1, const Marker& M2)
        {
            return M1.id < M2.id;
        }
        /**
         */
        friend std::ostream& operator<<(std::ostream& str, const Marker& M){
            str << M.id << "=";
            for (int i = 0; i < 4; i++)
                str << "(" << M[i].x << "," << M[i].y << ") ";
            if( !M.Tvec.empty() && !M.Rvec.empty()){
            str << "Txyz=";
            for (int i = 0; i < 3; i++)
                str << M.Tvec.ptr<float>(0)[i] << " ";
            str << "Rxyz=";
            for (int i = 0; i < 3; i++)
                str << M.Rvec.ptr<float>(0)[i] << " ";
            }
            return str;
        }


        // saves to a binary stream
        void toStream(std::ostream& str) const;
        // reads from a binary stream
        void fromStream(std::istream& str);

        // returns the 3d points of a marker wrt its center
        static vector<cv::Point3f> get3DPoints(float msize);
        //returns the 3d points of this marker wrt its center
          inline vector<cv::Point3f> get3DPoints()const{
              return get3DPoints(ssize);
          }

          //returns the SE3 (4x4) transform matrix

          cv::Mat getTransformMatrix()const;
    private:
        void rotateXAxis(cv::Mat& rotation);
    };

该类的一些重要特性和功能的解释:

成员变量:
id:标记的标识符
ssize:标记的边长(米)
RvecTvec:相对于相机的旋转和平移矩阵
dict_info:关于字典的附加信息
contourPoints:轮廓点的列表

构造函数和析构函数:
Marker()
Marker(int id)
Marker(const Marker& M)
Marker(const std::vector<cv::Point2f>& corners, int _id = -1)
~Marker()

公共成员函数:
isValid():判断标记对象是否有效
isPoseValid():判断标记的姿态是否有效
draw():在图像中绘制标记
calculateExtrinsics():计算相对于相机的外部参数
glGetModelViewMatrix():返回 OpenGL 的 GL_MODELVIEW 矩阵
OgreGetPoseParameters():返回用于 Ogre 场景节点或实体的位置向量和方向四元数
其他用于计算标记属性或进行变换的函数

运算符重载:
operator==():比较标记的标识符
operator=():赋值运算符重载
operator<():比较运算符重载
operator<<():输出流运算符重载

私有成员函数:
rotateXAxis():X 轴旋转函数
此类提供了对标记对象进行操作和绘制的功能,并且包含了一些额外的实用函数,用于计算标记的特征和进行姿态变换

总结

虽然代码中的注释显示既可以传入视频也可以传入图片,但是aruco_simple.cpp只能显示图片中检测到的标记或视频的第一帧

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值