C++ Eigen - 使用变换矩阵,实现两个传感器之间的坐标变换

1. 需求描述

实际项目中,有Lidar和 IMU两个传感器,上下背靠背安装。设Lidar所在坐标系为1系,IMU所在坐标系为2系,且均为右手系 。

其中2系的x轴与1系的y轴方向相同,2系的y轴与1系z轴方向相反,2系的z轴与1系的x轴相反,两个坐标系原点重.

求1系中 (0.2, 0.5, 1) 在2系中的坐标。请自己编写一个c++程序实现它,并用Cmake编译,得到能输出答案的可执行文件

在这里插入图片描述

2. 需求分析

整个变化经历了两次旋转:

经分析,整个变化经历了两次旋转(动轴):
(1)先绕Z轴旋转90度(逆时针);
(2)再绕第一次旋转后的X轴旋转90度(顺时针);

也可以看做(动轴):
(1)先绕Y轴旋转90度(顺时针);
(2)再绕第一次旋转后的Z轴旋转90度(顺时针);

我们以第一种旋转为例进行介绍。建议读者按第二种方案自行联系验证。

通过手工分析草图不难发现:

(1)在1系中的原始坐标为: P(x, y, z),即:P(0.2, 0.5, 1) ;

(2)经过第一次旋转后: 在m系中的坐标应为:P’(x’, y’, z’),即: P’(0.5, -0.2, 1);

(3)经过第二次旋转后: 在2系中的坐标应为:P"(x", y", z"),即: P"(0.5, -1, -0.2);

注:m 为middle的意思。

也就是: P(0.2, 0.5, 1) ----> P"(0.5, -1, -0.2)

在这里插入图片描述

3. 理论基础(来源于本人:实践中测试)

(1)在使用旋转矩阵、旋转向量等进行坐标旋转时,使用公式: P_2 = T_2_1 * P_1, 可将1系下的P点,旋转至2系下;

(2)对于 T_2_1 的计算,需要注意:若为顺时针旋转,则计算过程中旋转角度值为正数; 若为逆时针旋转,则计算过程中旋转角度值为负数;

(3)若T_2_1计算中,逆时针时也将旋转角度设置为了正数,则实际计算的 旋转矩阵为: T_1_2, 需要将计算结果再求逆: T_1_2.inverse();

注意:这里的顺时针、逆时针的判定标准为:以旋转无关轴 面向相关轴所构成平面的方向视角。

比如两次旋转是按照:

(1)先绕Z轴旋转90度:从Z轴面向X、Y轴所构成的平面的视角来看,为逆时针。

(2)再绕第一次旋转后的X轴旋转90度:从X轴面向Y、Z轴所构成的平面的视角来看,为顺时针;

在这里插入图片描述

若两次旋转是按照:

(1)先绕Y轴旋转90度(顺时针):从Y轴面向X、Z轴所构成的平面的视角来看,为顺时针;

(2)再绕第一次旋转后的Z轴旋转90度: 从Z轴面向X、Y轴所构成的平面的视角来看,为逆时针;

在这里插入图片描述

4. C++ Eigen代码 - 旋转方式1

两次旋转是按照:

(1)先绕Z轴旋转90度:从Z轴面向X、Y轴所构成的平面的视角来看,为逆时针。

(2)再绕第一次旋转后的X轴旋转90度:从X轴面向Y、Z轴所构成的平面的视角来看,为顺时针;



#include <iostream>
#include <cmath>

#include <Eigen/Core>
#include <Eigen/Geometry>



//变换矩阵: 旋转一个三维点
Eigen::Isometry3d GetIsometry(const Eigen::Vector3d& axis, double angle, const Eigen::Vector3d& translation)
{
  Eigen::Vector3d normalized_axis = axis.normalized();  // 归一化轴向量

  //转向量使用 AngleAxis, 它底层不直接是Matrix,但运算可以当作矩阵(因为重载了运算符)
  Eigen::AngleAxisd rotation_vector(angle, normalized_axis);

  Eigen::Isometry3d T = Eigen::Isometry3d::Identity();
  T.rotate(rotation_vector);
  T.pretranslate(translation);

  return T;
}



int main(int argc, char **argv)
{
    //构造第一次旋转的旋转矩阵
    Eigen::Isometry3d T_w1 = Eigen::Isometry3d::Identity();
    //方案1: 实现与验证:
    {
      double angle = -M_PI / 2;                 //旋转角度(弧度),例如90度(PI / 2)
      Eigen::Vector3d axis(0, 0, 1);  //定义旋转轴: 绕z轴旋转
      Eigen::Vector3d translation = Eigen::Vector3d(0, 0, 0); //定义平移量
      T_w1 = GetIsometry(axis, angle, translation);

      //for test: 分析8个单位顶点的坐标变化:
      {
        std::cout << "方案1(首次旋转):  Above Z plane: Eigen::Vector3d(0.2, 0.5, 1) --> Eigen::Vector3d(0.5, -0.2, 1), now is : " << (T_w1 * Eigen::Vector3d(0.2, 0.5, 1)).transpose() << std::endl;
      }
    }

    //方案2 实现与验证:
    Eigen::Isometry3d T_w1_temp = Eigen::Isometry3d::Identity();
    {
      double angle = M_PI / 2;                 //旋转角度(弧度),例如90度(PI / 2)
      Eigen::Vector3d axis(0, 0, 1);  //定义旋转轴: 绕z轴旋转
      Eigen::Vector3d translation = Eigen::Vector3d(0, 0, 0); //定义平移量
      T_w1_temp = GetIsometry(axis, angle, translation);

      //for test:  顶点的坐标变化:
      {
        std::cout << "方案2(首次旋转): Above Z plane: Eigen::Vector3d(0.2, 0.5, 1) --> Eigen::Vector3d(0.5, -0.2, 1), now is : " << (T_w1_temp.inverse() * Eigen::Vector3d(0.2, 0.5, 1)).transpose() << std::endl;
        std::cout << std::endl;
      }
    }
  }


  //构造第二次旋转的旋转矩阵
  Eigen::Isometry3d T_w2 = Eigen::Isometry3d::Identity();
  {
    double angle = M_PI / 2; // 旋转角度(弧度),例如90度(PI / 2)
    Eigen::Vector3d axis(1, 0, 0); //定义旋转轴: 绕X轴旋转
    Eigen::Vector3d translation = Eigen::Vector3d(0, 0, 0); //定义平移量
    T_w2 = GetIsometry(axis, angle, translation);
  }

  std::cout << "方案1(两次叠加):  Above Z plane: Eigen::Vector3d( 0.2, 0.5, 1) --> Eigen::Vector3d(0.5, -1, -0.2), now is : " << (T_w2 * (T_w1 * Eigen::Vector3d(0.2, 0.5, 1))).transpose() << std::endl;
  std::cout << "方案1(两次叠加, 矩阵满足结合律):  Above Z plane: Eigen::Vector3d( 0.2, 0.5, 1) --> Eigen::Vector3d(0.5, -1, -0.2), now is : " << (T_w2 * T_w1 * Eigen::Vector3d(0.2, 0.5, 1)).transpose() << std::endl;
  std::cout << std::endl;

  return 0;
}

5. C++ Eigen代码 - 旋转方式2

两次旋转是按照:

(1)先绕Z轴旋转90度:从Z轴面向X、Y轴所构成的平面的视角来看,为逆时针。

(2)再绕第一次旋转后的X轴旋转90度:从X轴面向Y、Z轴所构成的平面的视角来看,为顺时针;

两次旋转是按照:

(1)先绕Y轴旋转90度(顺时针):从Y轴面向X、Z轴所构成的平面的视角来看,为顺时针;

(2)再绕第一次旋转后的Z轴旋转90度: 从Z轴面向X、Y轴所构成的平面的视角来看,为逆时针;



#include <iostream>
#include <cmath>


#include <Eigen/Core>
#include <Eigen/Geometry>




//变换矩阵: 旋转一个三维点
Eigen::Isometry3d GetIsometry(const Eigen::Vector3d& axis, double angle, const Eigen::Vector3d& translation)
{
  Eigen::Vector3d normalized_axis = axis.normalized();  // 归一化轴向量

  //转向量使用 AngleAxis, 它底层不直接是Matrix,但运算可以当作矩阵(因为重载了运算符)
  Eigen::AngleAxisd rotation_vector(angle, normalized_axis);

  Eigen::Isometry3d T = Eigen::Isometry3d::Identity();
  T.rotate(rotation_vector);
  T.pretranslate(translation);

  return T;
}



int main(int argc, char **argv)
{
   //构造第一次旋转的旋转矩阵  
  Eigen::Isometry3d T_w1 = Eigen::Isometry3d::Identity();
  {
    //方案1: 实现与验证:
    {
      double angle = M_PI / 2;                 //旋转角度(弧度),例如90度(PI / 2)
      Eigen::Vector3d axis(0, 1, 0);  //定义旋转轴: 绕Y轴旋转
      Eigen::Vector3d translation = Eigen::Vector3d(0, 0, 0); //定义平移量
      T_w1 = GetIsometry(axis, angle, translation);

      //for test: 分析8个单位顶点的坐标变化:
      {
        //在Z平面(与Z轴垂直)以上,x,y 构成的二维平面中,逆时针寻找: (0.2, 0.5, 1), (-0.2, 0.5, 1), (-0.2,-0.5, 1), (0.2,-0.5, 1) 四个顶点
        std::cout << "方案1(首次旋转):  Above Z plane: Eigen::Vector3d(0.2, 0.5, 1) --> Eigen::Vector3d(0.5, -0.2, 1), now is : " << (T_w1 * Eigen::Vector3d(0.2, 0.5, 1)).transpose() << std::endl;
      }
    }

    //方案2 实现与验证:    
     Eigen::Isometry3d T_w1_temp = Eigen::Isometry3d::Identity();
    {
      double angle = -M_PI / 2;                 //旋转角度(弧度),例如90度(PI / 2)
      Eigen::Vector3d axis(0, 1, 0);  //定义旋转轴: 绕Y轴旋转
      Eigen::Vector3d translation = Eigen::Vector3d(0, 0, 0); //定义平移量
      T_w1_temp = GetIsometry(axis, angle, translation);

      //for test: 分析8个单位顶点的坐标变化:
      {
        //在Z平面(与Z轴垂直)以上,x,y 构成的二维平面中,逆时针寻找: (1, 1), (-1, 1), (-1, -1), (1, -1) 四个顶点
        std::cout << "方案2(首次旋转): Above Z plane: Eigen::Vector3d(0.2, 0.5, 1) --> Eigen::Vector3d(0.5, -0.2, 1), now is : " << (T_w1_temp.reverse() * Eigen::Vector3d(0.2, 0.5, 1)).transpose() << std::endl;
      }
    }
  }


  //构造第二次旋转的旋转矩阵
   Eigen::Isometry3d T_w2 = Eigen::Isometry3d::Identity();
  {
    double angle = -M_PI / 2; // 旋转角度(弧度),例如90度(PI / 2)
    Eigen::Vector3d axis(0, 0, 1); //定义旋转轴: 绕Z轴旋转
    Eigen::Vector3d translation = Eigen::Vector3d(0, 0, 0); //定义平移量
    T_w2 = GetIsometry(axis, angle, translation);
  }


  std::cout << "方案1(两次叠加):  Above Z plane: Eigen::Vector3d(0.2, 0.5, 1) --> Eigen::Vector3d(0.5, -1, -0.2), now is : " << (T_w2 * (T_w1 * Eigen::Vector3d(0.2, 0.5, 1))).transpose() << std::endl;
  std::cout << "方案1(两次叠加, 矩阵满足结合律):  Above Z plane: Eigen::Vector3d(0.2, 0.5, 1) --> Eigen::Vector3d(0.5, -1, -0.2), now is : " << (T_w2 * T_w1 * Eigen::Vector3d(0.2, 0.5, 1)).transpose() << std::endl;
  std::cout << std::endl;

  return 0;
}


<think>我们使用C++Eigen实现遗传算法来求解三维变换矩阵。三维变换矩阵通常由旋转矩阵R和平移向量t组成,我们使用四元数表示旋转(避免万向锁问题),因此染色体将包含四元数(4个分量)和平移向量(3个分量),共7个参数。步骤:1.定义个体(染色体)结构:使用一个Eigen向量或普通数组存储7个参数[qx,qy,qz,qw,tx,ty,tz]。2.初始化种群:随机生成多个个体。3.定义适应度函数:计算变换后点云与目标点云的距离(如均方误差MSE),适应度取负MSE(因为我们要最大化适应度,而误差越小越好)。4.选择操作:锦标赛选择。5.交叉操作:混合交叉(BLX-alpha)或模拟二进制交叉(SBX)。6.变异操作:高斯变异或均匀变异。7.迭代进化:直到满足停止条件(如达到最大代数或适应度收敛)。我们将使用Eigen库进行点云变换计算,包括四元数到旋转矩阵的转换,然后应用旋转和平移。注意:在适应度函数中,我们需要将四元数归一化,以保证其表示有效的旋转。由于C++没有像Python那样的DEAP库,我们将自己实现简单的遗传算法框架。代码结构:-定义点云类型(Eigen::MatrixXd,每行一个点)-定义个体类型(std::vector<double>,大小为7)-计算变换函数:根据个体参数,将源点云变换到目标坐标系-适应度函数:计算变换后点云与目标点云的平均距离(取负值)-初始化种群-选择、交叉、变异操作-主循环为了简单起见,我们使用固定种群大小和固定代数的遗传算法。以下是一个示例代码框架:</think> # 使用C++Eigen实现遗传算法求解三维变换矩阵 我将使用C++Eigen实现一个遗传算法求解三维变换矩阵(旋转矩阵$R$和平移向量$t$)的方案。三维变换矩阵通常表示为: $$\begin{bmatrix} R & t \\ 0 & 1 \end{bmatrix}$$ 其中$R \in \mathbb{R}^{3\times3}$是旋转矩阵,$t \in \mathbb{R}^{3}$是平移向量。 ## 解决方案实现 ```cpp #include <iostream> #include <vector> #include <random> #include <algorithm> #include <Eigen/Dense> #include <cmath> using namespace Eigen; using namespace std; // 个体结构:包含旋转矩阵、平移向量和适应度 struct Individual { Matrix3d R; Vector3d t; double fitness; }; // 计算变换后点云 MatrixXd transformPoints(const MatrixXd& points, const Matrix3d& R, const Vector3d& t) { return (R * points.transpose()).transpose().rowwise() + t.transpose(); } // 计算适应度(点云之间的距离,使用Hausdorff距离) double computeFitness(const MatrixXd& source, const MatrixXd& target, const Matrix3d& R, const Vector3d& t) { const MatrixXd transformed = transformPoints(source, R, t); // 计算Hausdorff距离 double maxDist = 0.0; for (int i = 0; i < transformed.rows(); ++i) { double minDist = numeric_limits<double>::max(); for (int j = 0; j < target.rows(); ++j) { const double dist = (transformed.row(i) - target.row(j)).norm(); if (dist < minDist) minDist = dist; } if (minDist > maxDist) maxDist = minDist; } return -maxDist; // 负值用于最大化问题 } // 生成随机变换矩阵 Individual generateRandomIndividual(mt19937& rng, double maxRotation, double maxTranslation) { uniform_real_distribution<double> angleDist(-maxRotation, maxRotation); uniform_real_distribution<double> transDist(-maxTranslation, maxTranslation); // 随机生成三个旋转角度 const double alpha = angleDist(rng); // 绕X轴 const double beta = angleDist(rng); // 绕Y轴 const double gamma = angleDist(rng); // 绕Z轴 // 构建旋转矩阵 Individual ind; ind.R = AngleAxisd(alpha, Vector3d::UnitX()) * AngleAxisd(beta, Vector3d::UnitY()) * AngleAxisd(gamma, Vector3d::UnitZ()); // 随机平移向量 ind.t = Vector3d(transDist(rng), transDist(rng), transDist(rng)); return ind; } // 变异操作 void mutate(Individual& ind, mt19937& rng, double mutationRate, double maxRotationDelta, double maxTranslationDelta) { uniform_real_distribution<double> rotationDist(-maxRotationDelta, maxRotationDelta); uniform_real_distribution<double> translationDist(-maxTranslationDelta, maxTranslationDelta); if (uniform_real_distribution<double>(0, 1)(rng) < mutationRate) { // 添加随机小角度旋转 const double alpha = rotationDist(rng); const double beta = rotationDist(rng); const double gamma = rotationDist(rng); Matrix3d deltaR = AngleAxisd(alpha, Vector3d::UnitX()) * AngleAxisd(beta, Vector3d::UnitY()) * AngleAxisd(gamma, Vector3d::UnitZ()); ind.R = deltaR * ind.R; } if (uniform_real_distribution<double>(0, 1)(rng) < mutationRate) { // 添加随机小位移 ind.t += Vector3d(translationDist(rng), translationDist(rng), translationDist(rng)); } } // 交叉操作(线型插值) Individual crossover(const Individual& parent1, const Individual& parent2, mt19937& rng) { uniform_real_distribution<double> weightDist(0, 1); const double w = weightDist(rng); // 交叉权重 // 四元数插值更优,这里简化使用旋转矩阵线性插值 Matrix3d R = (1 - w) * parent1.R + w * parent2.R; // 正交化R以保持旋转矩阵性质 JacobiSVD<MatrixXd> svd(R, ComputeThinU | ComputeThinV); R = svd.matrixU() * svd.matrixV().transpose(); // 线性插值平移向量 Vector3d t = (1 - w) * parent1.t + w * parent2.t; return {R, t, 0.0}; } // 遗传算法主体 tuple<Matrix3d, Vector3d> geneticRegistration( const MatrixXd& sourcePoints, const MatrixXd& targetPoints, int populationSize = 50, int generations = 100, double mutationRate = 0.2, double eliteRatio = 0.1, double maxRotation = M_PI/4, double maxTranslation = 5.0, int seed = 42 ) { mt19937 rng(seed); // 初始化种群 vector<Individual> population; for (int i = 0; i < populationSize; ++i) { Individual ind = generateRandomIndividual(rng, maxRotation, maxTranslation); ind.fitness = computeFitness(sourcePoints, targetPoints, ind.R, ind.t); population.push_back(ind); } // 主进化循环 for (int gen = 0; gen < generations; ++gen) { // 评估适应度(在新个体生成时已计算) // 排序种群(适应度从高到低) sort(population.begin(), population.end(), [](const Individual& a, const Individual& b) { return a.fitness > b.fitness; }); // 精英选择 vector<Individual> newPopulation; const int eliteCount = static_cast<int>(populationSize * eliteRatio); for (int i = 0; i < eliteCount; ++i) { newPopulation.push_back(population[i]); } // 生成新个体(选择+交叉+变异) while (newPopulation.size() < populationSize) { // 锦标赛选择 uniform_int_distribution<int> selectDist(0, populationSize - 1); int idx1 = selectDist(rng); int idx2 = selectDist(rng); if (population[idx1].fitness < population[idx2].fitness) swap(idx1, idx2); int idx3 = selectDist(rng); int idx4 = selectDist(rng); if (population[idx3].fitness < population[idx4].fitness) swap(idx3, idx4); // 交叉 Individual child = crossover(population[idx1], population[idx3], rng); // 变异 mutate(child, rng, mutationRate, maxRotation * 0.05, maxTranslation * 0.05); // 计算适应度并加入新种群 child.fitness = computeFitness(sourcePoints, targetPoints, child.R, child.t); newPopulation.push_back(child); } population = move(newPopulation); } // 选择最优个体 sort(population.begin(), population.end(), [](const Individual& a, const Individual& b) { return a.fitness > b.fitness; }); return {population[0].R, population[0].t}; } // 生成随机点云 MatrixXd generateRandomPointCloud(int n) { MatrixXd points(n, 3); random_device rd; mt19937 gen(rd()); uniform_real_distribution<double> dist(-10.0, 10.0); for (int i = 0; i < n; ++i) { points.row(i) = Vector3d(dist(gen), dist(gen), dist(gen)); } return points; } int main() { // 生成源点云 const int numPoints = 100; MatrixXd sourcePoints = generateRandomPointCloud(numPoints); // 创建真实变换矩阵 AngleAxisd rotation(M_PI/4, Vector3d(1, 1, 1).normalized()); Vector3d translation(2.0, -1.5, 3.0); // 应用变换到源点云得到目标点云 MatrixXd targetPoints = transformPoints(sourcePoints, rotation.matrix(), translation); // 添加少量噪声 random_device rd; mt19937 gen(rd()); normal_distribution<double> noise(0.0, 0.05); for (int i = 0; i < targetPoints.rows(); ++i) { for (int j = 0; j < 3; ++j) { targetPoints(i, j) += noise(gen); } } // 使用遗传算法估计变换 auto [estimatedR, estimatedT] = geneticRegistration( sourcePoints, targetPoints, 100, // 种群大小 200, // 代数 0.25, // 变异率 0.15 // 精英比例 ); // 输出结果 cout << "真实旋转矩阵:\n" << rotation.matrix() << endl; cout << "\n估计旋转矩阵:\n" << estimatedR << endl; cout << "\n旋转矩阵误差:\n" << estimatedR - rotation.matrix() << endl; cout << "\n真实平移向量:\n" << translation.transpose() << endl; cout << "\n估计平移向量:\n" << estimatedT.transpose() << endl; cout << "\n平移误差: " << (translation - estimatedT).norm() << endl; // 计算点云距离 MatrixXd transformedPoints = transformPoints(sourcePoints, estimatedR, estimatedT); double error = 0.0; for (int i = 0; i < numPoints; ++i) { error += (transformedPoints.row(i) - targetPoints.row(i)).norm(); } error /= numPoints; cout << "\n平均点云距离: " << error << endl; return 0; } ``` ## 算法关键组成部分 ### 1. 三维变换矩阵表示 使用Eigen库的`Matrix3d`表示旋转矩阵$R$,`Vector3d`表示平移向量$t$。三维空间变换定义为: $$P_{transformed} = R \cdot P + t$$ 其中$P \in \mathbb{R}^{3}$是点坐标。 ### 2. 遗传算法流程 - **初始化种群**:随机生成旋转和平移参数 - **适应度评估**:使用Hausdorff距离计算点云对齐质量 - **选择**:锦标赛选择保留优质个体 - **交叉**:使用线性插值进行旋转变换矩阵和平移向量插值 - **变异**:添加随机小角度旋转和小位移 - **精英保留**:每代保留最优个体防止退化 ### 3. 点云对齐度量 使用Hausdorff距离作为适应度函数: $$\operatorname{hausdorff}(S, T) = \max_{s \in S} \min_{t \in T} ||s - t||$$ 该距离对点云的部分重叠具有鲁棒性[^1]。 ### 4. 保持旋转矩阵正交性 在交叉操作中使用了奇异值分解来确保旋转矩阵保持正交: $$\operatorname{orthogonalize}(M) = U \cdot V^\top$$ 其中$U$和$V$来自矩阵$M$的SVD分解$M = U \Sigma V^\top$[^2]。 ## 性能优化建议 1. **点云降采样**:对于大规模点云,使用Voxel Grid等降采样方法加速计算 2. **局部优化**:在遗传算法后接ICP(迭代最近点)进行精确配准: ```cpp // 简化的ICP实现 void refineWithICP(MatrixXd& source, MatrixXd& target, Matrix3d& R, Vector3d& t, int maxIters = 20) { for (int iter = 0; iter < maxIters; ++iter) { // 建立点对应关系 MatrixXd transformed = transformPoints(source, R, t); vector<pair<int, int>> correspondences = findNearestNeighbors(transformed, target); // 计算最优变换 tie(R, t) = solveOptimalTransform(source, target, correspondences); } } ``` 3. **并行评估**:使用OpenMP或TBB并行化适应度评估 ## 相关问题 1. 如何评估三维变换矩阵求解算法的性能? 常用指标包括旋转误差$||\log(R_{\text{true}}^T R_{\text{est}})||$、平移误差$||t_{\text{true}} - t_{\text{est}}||$,以及点云平均匹配距离[^1]。 2. 在点云配准中如何处理非刚性变形? 非刚性变换需要使用不同的变换模型,如薄板样条(TPS)或基于特征的方法。可以结合使用遗传算法优化非刚性变换参数[^2]。 3. 除了Hausdorff距离,还有哪些常用的点云配准评估指标? 常用指标包括点到点距离(Point-to-Point)、点到平面距离(Point-to-Plane)、倒角距离(Chamfer Distance)、地球移动距离(EMD)等[^1]。 [^1]: 旋转矩阵和平移变换的基本操作 [^2]: 坐标变换中的平移向量方向处理
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Adunn

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

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

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

打赏作者

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

抵扣说明:

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

余额充值