OpenCV拾趣(八)——无标记AR·书本封面跟踪

本篇简介

本小节我们来实现一个相对复杂的应用:无标记AR——具体而言,在视频流中实时跟踪指定书本封面并绘制附着在之上的AR图形。这样描述可能有点抽象,我们先来看一下最终实现的效果:


book_track.png

也就是在采集到的场景中,找到《深入理解OpenCV》这本书的位置,并在它的封面上绘制一个表示这本书姿态的3D小方块。
这里先额外说明几点:

  1. 为什么这个应用叫做无标记AR?因为与之相对的有标记AR,需要借助特殊的AR标记物,例如二维码,来在真实场景中绘制AR图形。而无标记AR仅需借助事先训练好的真实物体模型,就可以完成AR图形的绘制
  2. 本篇的很多实现思路都参考了《深入理解OpenCV》相关章节的实现,有兴趣的话推荐大家把这本书找来翻一翻,里面有一些比较有意思的案例可以参考

基本原理及流程

整体流程

首先我们来梳理一下实现这样一个应用需要那些基本功能和步骤。就基本原理而言,我们使用基于特征的匹配方式来比对目标模型和现实场景,因此整体流程分为以下几个步骤:

  1. 对目标物体进行建模,也就是需要对目标物体进行特征提取
  2. 使用目标物体的特征模型训练匹配器
  3. 使用训练好的匹配器将目标模型与实时场景进行匹配、计算目标物体在场景内的姿态
  4. 根据匹配结果绘制对应的AR图形

从上述四个步骤可以看出,整个应用的功能可以按阶段划分为训练阶段匹配阶段
在开始着手实现这两个阶段的具体功能前,我们先来准备一个模式数据类作为存放基础数据的载体,顺便简单讲解一下接下来的实现中涉及到的一些基本概念。

实现模式数据类Pattern

模式数据类用于存放各类基础模式数据。之后的实现中,目标物体训练集的结果,将以这个数据类为载体进行存储。其声明如下:

#include <opencv2/core.hpp>
#include <vector>

using namespace cv;
using namespace std;

class Pattern
{
  // 省略构造方法和getter/setter
  ...

  private:
    Size m_size; // 图像大小
    Mat m_data;  // 原始图像
    vector<KeyPoint> m_keypoints;
    Mat m_descriptors;

    vector<Point2f> m_points2d;
    vector<Point3f> m_points3d;
};

可以看到模式数据类中,除了作为辅助信息的图像大小和原始图像外,还包含下面几种重要的数据:

  • 特征点数组(key points)和其对应的描述符(descriptors):基于特征的匹配方式最基础的数据。特征点用于标识目标物体相对于一般场景最显著的差异位置,而描述符则是对这些差异的具体描述
  • 描述目标物体边界的2D和3D点集(m_points2d和m_points3d)

简单总结就是,通过边界限定+特征点和描述的集合来实现对目标物体的建模。

完成了基础模式数据类的设计后,我们来根据上面整理的两个阶段,规划下需要进一步实现的其他工具类。总结起来,我们需要实现下面几个功能:

  • 对目标物体的建模
  • 在检测场景中检测目标物体
  • 目标物体的姿态估计和绘制

简明起见,我们将前两个功能整合到统一的模式检测器(PatternDetector)工具类中,最后一个功能因为涉及到界面绘制,我们将它分离出来单独实现一个模式跟踪器(PatternTracker),并通过组合的方式将这三类功能串起来,统一由模式检测器向操作界面提供功能接口。

模式检测器的声明如下,其具体的方法会在后文详细讲解,这里我们先只关注其中比较重要的成员变量:

#include <opencv2/core.hpp>
#include <opencv2/objdetect.hpp>
#include <opencv2/xfeatures2d.hpp>

#include "Pattern.h"
#include "PatternTracker.h"

using namespace cv;
using namespace std;

class PatternDetector : public QObject
{
    Q_OBJECT
  
  // 省略构造和析构方法
  ...

  public:
    void train(const Mat& img, Mat& featureImg);
    bool findPatternFromScene(const Mat& sceneImg);
    bool computePose(const CameraIntrinsic& intrinsic);
    
  // 省略部分getter方法和私有方法
  ...

  private:
    
    // 特征检测器
    Ptr<FeatureDetector> m_detector;
    // 描述符匹配器
    Ptr<DescriptorMatcher> m_matcher;
    // 模式跟踪器
    PatternTracker* m_patternTracker;

    // 目标模型模式数据(训练集)
    Ptr<Pattern> m_pattern;

    // 查询集
    Mat m_queryDescriptors;
    
    // 单应变换矩阵(缓存用)
    Mat m_homography;

};

上面的声明中,除了包含组合了训练集和查询集的基础数据外,还组合了OpenCV提供的工具特征检测器描述符匹配器,这两个工具的具体使用我们放到后文讲到对应的处理流程时再详细说明。而
上文所述的模式跟踪器声明如下:

#include <opencv2/core.hpp>

#include "Pattern.h"
#include "component/QCvCamera.h"

using namespace cv;
using namespace std;

class PatternTracker : public QObject
{
    Q_OBJECT
 
  // 省略构造、析构和gettter/setter方法
  ...

    void draw2DContour(Mat& bgImg);
    void draw3DCube(Mat& bgImg, const CameraIntrinsic& intrinsic);

  private:
    vector<Point2f> m_points2d;
    Pose m_pose;
};

也就是提供了绘制目标物体2D轮廓和3D姿态AR图像的方法。需要特别说明的是,这里引用QCvCamera.h头文件是为了使用我们在第五小节实现的标定数据管理功能。

接下来我们就分别来详细看看如何使用这些工具类实现训练和检测阶段所需要的各类功能。

训练阶段

首先简单讲解一下我们对几个关键组件,也就是特征检测器描述符匹配器类型的选择:

    m_detector = Ptr<FeatureDetector>(xfeatures2d::SURF::create());
    m_matcher = Ptr<DescriptorMatcher>(new FlannBasedMatcher());

可以看到我们选取了SURF特征及其对应的特征检测器,而匹配器我们选择了基于FLANN的匹配器,以实现快速近似临近搜索。有关SURF特征值及FLANN的具体原理和特性,限于篇幅就不在这里展开说明了,有兴趣的话可以参考文末的参考链接[1][2][3]

而使用这两个组件进行训练的过程实现如下:

void PatternDetector::train(const Mat& img, Mat& featureImg)
{
    if (!img.empty())
    {
        featureImg = img.clone();

        m_pattern = generatePattern(img);

        // train matcher
        m_matcher->clear();
        m_matcher->add(m_pattern->descriptors());
        m_matcher->train();

        // generate retMat
        for (size_t i = 0; i < m_pattern->keypoints().size(); i++)
        {
            cv::putText(featureImg, "+", m_pattern->keypoints()[i].pt, cv::FONT_HERSHEY_PLAIN, 1, cv::Scalar(0, 0, 255));
        }
    }
}

也就是分为了这么几个步骤:

  1. 从训练图像中生成模式数据(generatePattern)
  2. 使用模式数据中提取好的描述符训练匹配器
  3. 将提取得到的关键点绘制在训练图像上供展示

这其中最关键的生成模式数据方法实现如下:

Ptr<Pattern> PatternDetector::generatePattern(const Mat& img)
{
    // 转化灰度图像
    Mat grayImg;
    if (img.type() == CV_8UC1)
    {
        grayImg = img.clone();
    }
    else
    {
        cvtColor(img, grayImg, cv::COLOR_BGR2GRAY);
    }
    // 提取训练集特征
    std::vector<cv::KeyPoint> keypoints;
    cv::Mat descriptors;
    extractFeature(img, keypoints, descriptors);

    // 计算模型边界
    vector<Point2f> points2d = vector<Point2f>(4);
    vector<Point3f> points3d = vector<Point3f>(4);

    const float w = img.cols;
    const float h = img.rows;
    const float maxSize = std::max(w, h);
    const float unitW = w / maxSize;
    const float unitH = h / maxSize;

    points2d[0] = Point2f(0, 0);
    points2d[1] = Point2f(w, 0);
    points2d[2] = Point2f(w, h);
    points2d[3] = Point2f(0, h);

    points3d[0] = Point3f(-unitW, unitH, 0);
    points3d[1] = Point3f(unitW, unitH, 0);
    points3d[2] = Point3f(unitW, -unitH, 0);
    points3d[3] = Point3f(-unitW, -unitH, 0);
    
    // 生成模式数据对象
    cv::Size imgSize = Size(img.cols, img.rows);
    cv::Mat origImg = img.clone();
    return Ptr<Pattern>(new Pattern(imgSize, origImg,
                                    keypoints, descriptors,
                                    points2d, points3d));
}

这其中涉及到的主要步骤在上面的注释中已经写了,就不再赘述了。需要说明的是,因为我们的实际场景是对书本封面的建模,所以这里计算模型边界的过程很简单,就是讲书本封面这个平面的2D和3D坐标计算出来就可以了,而对于3D坐标系,我们将坐标原点设置在了书本封面的中心点,比例尺则简单做了一下归一化计算。

上面的步骤中涉及到的提取特征过程放在了另一个方法extractFeature中实现,具体如下:

void PatternDetector::extractFeature(const Mat& img, vector<KeyPoint>& keypoints, Mat& descriptors)
{
    m_detector->detect(img, keypoints);
    // 过滤重复的关键点,并仅保留特征最明显的500个点
    KeyPointsFilter::removeDuplicated(keypoints);
    KeyPointsFilter::retainBest(keypoints, 500);
    // 计算每个关键点的描述符
    m_detector->compute(img, keypoints, descriptors);
}

可以看到OpenCV提供的检测器工具除了提供基本的特征检测和描述符计算功能外,还很提供了包括剔除重复点、设置保留个数在内的便捷功能。

这样整个训练阶段的实现就完成了,接下来我们来看一看相对复杂一些的匹配阶段。

匹配阶段

如前文所述,整个匹配阶段分为对目标物体模式的匹配和场景中匹配到的模式姿态估计两部分,接下来分别就这两个步骤进行说明。

模式匹配

模式匹配过程通过下面的方法实现:

bool PatternDetector::findPatternFromScene(const Mat& sceneImg)
{
    if (m_pattern == NULL)
    {
        qWarning() << "No valid pattern!";
        return false;
    }

    std::vector<cv::KeyPoint> keypoints;
    // 提取查询集特征
    extractFeature(sceneImg, keypoints, m_queryDescriptors);
    if (keypoints.size() >= m_pattern->keypoints().size())
    {
        vector<DMatch> matches;
        // 计算匹配结果
        getMatches(m_queryDescriptors, matches);
        ...
        // 省略单应计算内容,后文详述
    }
    else
    {
        return false;
    }
}

在上面的实现中,我们首先使用上文介绍过的extractFeature方法提取场景中的特征形成查询集,之后通过getMatches方法计算训练集与查询集的匹配度。getMatches方法具体实现如下:

void PatternDetector::getMatches(const Mat& descriptors, vector<DMatch>& matches)
{
    const float minRatio = 1.f / 1.5f;
    vector<vector<DMatch>> knnMatches;
    m_matcher->knnMatch(descriptors, knnMatches, 2);
    for (size_t i = 0; i < knnMatches.size(); i++)
    {
        const DMatch& bestMatch = knnMatches[i][0];
        const DMatch& betterMatch = knnMatches[i][1];

        float distRatio = bestMatch.distance / betterMatch.distance;
        if (distRatio < minRatio)
        {
            matches.push_back(bestMatch);
        }
    }
}

这里的实现我们采用了knn查找作为基本算法,并通过限制最佳匹配与次佳匹配比的阈值来剔除匹配中可能出现的离散值(false negative)。

模式姿态估计

在完成场景中目标模式的匹配后,接下来需要计算模式在场景中的姿态。对此,首先我们要计算出训练集和查询集中关键点集的单应性,实现如下:

    // 计算单应性矩阵并细化
    bool homographyFound = refineMatchesWithHomography(keypoints, matches, m_homography);
    if (homographyFound)
    {
        refineHomography(sceneImg);
        vector<Point2f> trackerPt2D;
        perspectiveTransform(m_pattern->points2d(), trackerPt2D, m_homography);
        m_patternTracker->setPoints2D(trackerPt2D);
        return true;
    }
    else
    {
        return false;
    }

其中,refineMatchesWithHomography方法实现如下:

bool PatternDetector::refineMatchesWithHomography(const vector<KeyPoint>& queryKeypoints, vector<DMatch>& matches, Mat& homography)
{
    const int minMatchesAllowed = 8;
    if (matches.size() < minMatchesAllowed)
    {
        return false;
    }
    vector<Point2f> srcPts(matches.size());
    vector<Point2f> dstPts(matches.size());
    for (size_t i = 0; i < matches.size(); i++)
    {
        srcPts[i] = m_pattern->keypoints()[matches[i].trainIdx].pt;
        dstPts[i] = queryKeypoints[matches[i].queryIdx].pt;
    }
    vector<unsigned char> inliersMask(srcPts.size());
    homography = cv::findHomography(srcPts, dstPts, CV_FM_RANSAC, 3, inliersMask);
    vector<DMatch> inliers;
    for (size_t i = 0; i < inliersMask.size(); i++)
    {
        if (inliersMask[i])
            inliers.push_back(matches[i]);
    }
    matches.swap(inliers);
    return matches.size() >= minMatchesAllowed;
}

也就是从匹配结果中分别取出训练集和查询集的点id,并通过OpenCV的findHomography方法计算出单应性矩阵。而计算出的单应性矩阵,可以通过下面的方法进行进一步的细化:

void PatternDetector::refineHomography(const Mat& sceneImg)
{
    Mat warppedImg;
    warpPerspective(sceneImg, warppedImg,
                    m_homography, m_pattern->size(),
                    WARP_INVERSE_MAP | INTER_CUBIC);
    vector<KeyPoint> warpedKeypoints;
    vector<DMatch> refinedMatches;
    extractFeature(warppedImg, warpedKeypoints, m_queryDescriptors);
    getMatches(m_queryDescriptors, refinedMatches);
    Mat refinedHomography;
    bool homographyFound = refineMatchesWithHomography(warpedKeypoints,
                                                       refinedMatches,
                                                       refinedHomography);
    if (homographyFound)
    {
        m_homography = m_homography * refinedHomography;
    }
}

这里的基本原理是:利用计算得到的单应性矩阵,将场景中匹配到的目标模式进行反向透视投影,并与模式模型中的特征进行比对,从而实现单应矩阵的细化。
上面的实现中,反向透视投影的计算通过OpenCV提供的warpPerspective方法完成。

完成了单应矩阵的计算和细化后,我们就可以通过OpenCV的perspectiveTransform方法计算目标模式在场景中对应的边界点位置,并计算结果提供给模式跟踪器了。

最后,模式的姿态估计方法实现如下:

bool PatternDetector::computePose(const CameraIntrinsic& intrinsic)
{
    if (m_pattern == NULL)
    {
        qWarning() << "No valid pattern!";
        return false;
    }
    if (m_patternTracker->ponits2d().empty())
    {
        qWarning() << "Pattern tracker 2D perspective not valid!";
        return false;
    }

    Mat rotation, translation;
    if (!solvePnP(m_pattern->points3d(), m_patternTracker->ponits2d(),
                  intrinsic.cameraMat, intrinsic.distortCoeff,
                  rotation, translation))
    {
        qWarning() << "Failed solving PnP!";
        return false;
    }
    Pose pose(rotation, translation);
    m_patternTracker->setPose(pose);

    return true;
}

简而言之,就是使用上面计算的,场景中的边界点(ponits2d),借助OpenCV提供的solvePnP方法,计算出目标模式在场景中的旋转(rotation)和平移(translation),并将结果存储到模式跟踪器中。

从上面的实现中可以看到,我们将相机的内参(CameraIntrinsic)作为入参传入了进来参与了solvePnP的计算。有关相机内参的获取,可以参考第七小节的相关说明。

而模式跟踪器得到这些计算好的结果后,只需要借助OpenCV和Qt提供的各类绘制功能将结果展现在图像上就可以了,例如下面的绘制3D方块的方法:

void PatternTracker::draw3DCube(Mat& bgImg, const CameraIntrinsic& intrinsic)
{
    vector<Point3d> vispts3d;
    vector<Point2d> vispts2d;
    // axis
    vispts3d.push_back(Point3d(0, 0, 0));
    vispts3d.push_back(Point3d(1, 0, 0));
    vispts3d.push_back(Point3d(0, 1, 0));
    vispts3d.push_back(Point3d(0, 0, 1));
    // cube
    double cubeSize = 0.5;
    vispts3d.push_back(Point3d(-cubeSize, -cubeSize, cubeSize));
    vispts3d.push_back(Point3d(cubeSize, -cubeSize, cubeSize));
    vispts3d.push_back(Point3d(cubeSize, cubeSize, cubeSize));
    vispts3d.push_back(Point3d(-cubeSize, cubeSize, cubeSize));
    vispts3d.push_back(Point3d(-cubeSize, -cubeSize, 0));
    vispts3d.push_back(Point3d(cubeSize, -cubeSize, 0));
    vispts3d.push_back(Point3d(cubeSize, cubeSize, 0));
    vispts3d.push_back(Point3d(-cubeSize, cubeSize, 0));
    // project 3D->2D
    projectPoints(vispts3d, m_pose.rotation, m_pose.translation,
                  intrinsic.cameraMat, intrinsic.distortCoeff, vispts2d);
    // draw cube
    vector<Point> face{vispts2d[5], vispts2d[4], vispts2d[7],
                       vispts2d[11], vispts2d[10], vispts2d[9]};
    vector<vector<Point>> faces{face};
    fillPoly(bgImg, faces, Scalar(200, 128, 128, 100));
    Scalar cubeColor = Scalar(200, 200, 200);
    for (int i = 4; i <= 10; i++)
    {
        if (i <= 7)
        {
            cv::line(bgImg, vispts2d[i], vispts2d[i + 4], cubeColor, 3);
        }
        if (i != 7)
        {
            cv::line(bgImg, vispts2d[i], vispts2d[i + 1], cubeColor, 3);
        }
    }
    cv::line(bgImg, vispts2d[7], vispts2d[4], cubeColor, 3);
    cv::line(bgImg, vispts2d[11], vispts2d[8], cubeColor, 3);
    // draw axis
    cv::line(bgImg, vispts2d[0], vispts2d[1], Scalar(255, 0, 0), 3);
    cv::line(bgImg, vispts2d[0], vispts2d[2], Scalar(0, 255, 0), 3);
    cv::line(bgImg, vispts2d[0], vispts2d[3], Scalar(0, 0, 255), 3);
}

因为上面的计算结果大多使用OpenCV提供的数据结构类存储,因此这里为方便使用这些数据,也使用了OpenCV提供的绘制工具进行绘制。之后在界面上展示时,使用我们前面几讲中实现好的转换工具转换为Qt支持的图像格式即可。
至此我们的各类核心功能就实现完成了,接下来我们来实现操作界面将这些功能整合起来。

实现操作界面

依照惯例,首先我们实现连接视频流控件和具体功能的滤波器类:

void QCvMatchResultFilter::execFilter(const Mat& inMat, Mat& outMat)
{
    outMat = inMat.clone();
    if (inMat.empty() || m_detector == NULL)
    {
        return;
    }
    else
    {
        Pose pose;
        if (m_detector->findPatternFromScene(inMat))
        {
            const QCvCamera* camera = QCvMatFilter::camera();
            if (camera != NULL && camera->isIntrinsicValid() &&
                m_detector->computePose(camera->intrinsic()))
            {
                //draw 3d
                m_detector->tracker()->draw3DCube(outMat, camera->intrinsic());
            }
            else
            {
                //draw 2d
                m_detector->tracker()->draw2DContour(outMat);
            }
        }
    }
}

经过上面工具类的封装,这里的实现就相对比较单纯了。首先通过模式跟踪器的模式匹配入口findPatternFromScene方法检测目标模式是否在场景中,然后判断是否已加载了相机内参数据,如果加载了就绘制3D姿态方块,否则只绘制2D轮廓。
实现了滤波器后,我们最后来着手实现最终的图形界面,UI设计如下:


book_gui.png

除了左侧的展示区外,还包含了加入相机内参数据的按钮、加载训练图片和训练按钮,以及开始匹配跟踪的按钮。
各个按钮的详细实现限于篇幅就不详细贴了,这里只说明一下比较关键的几个实现步骤:

  • 相机内参数据读取:只需要获取内参文件的文件名,然后调用QCvCamView封装好的updateCalibrarion方法即可
  • 训练模式模型:调用模式检测器的train方法即可
  • 开始匹配跟踪:初始化视频流控件时添加上面实现好的滤波器,在点击开始按钮时开启视频流即可

测试跟踪效果

最终实现完成的测试效果如下:

  • 加载训练图片并训练模式模型,结果如下:


    book_train.png

    其中红色的十字即为训练时检测到的特征点。

  • 在没有加载相机内参时,匹配跟踪效果如下:


    book2D.png
  • 加载了相机内参后,匹配跟踪结果如下:


    book_track.png

那么关于这个相对复杂的无标记AR范例就先讲解到这里。
>>本篇参考代码
>>返回系列索引

参考链接

[1] SURF Wikipedia
[2] FLANN官网
[3] OpenCV Flann匹配教程
[4] 图像单应性Wikipedia

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

推荐阅读更多精彩内容