OpenCV拾趣(八)——無標(biāo)記AR·書本封面跟蹤

本篇簡介

本小節(jié)我們來實現(xiàn)一個相對復(fù)雜的應(yīng)用:無標(biāo)記AR——具體而言,在視頻流中實時跟蹤指定書本封面并繪制附著在之上的AR圖形。這樣描述可能有點抽象,我們先來看一下最終實現(xiàn)的效果:


book_track.png

也就是在采集到的場景中,找到《深入理解OpenCV》這本書的位置,并在它的封面上繪制一個表示這本書姿態(tài)的3D小方塊。
這里先額外說明幾點:

  1. 為什么這個應(yīng)用叫做無標(biāo)記AR?因為與之相對的有標(biāo)記AR,需要借助特殊的AR標(biāo)記物,例如二維碼,來在真實場景中繪制AR圖形。而無標(biāo)記AR僅需借助事先訓(xùn)練好的真實物體模型,就可以完成AR圖形的繪制
  2. 本篇的很多實現(xiàn)思路都參考了《深入理解OpenCV》相關(guān)章節(jié)的實現(xiàn),有興趣的話推薦大家把這本書找來翻一翻,里面有一些比較有意思的案例可以參考

基本原理及流程

整體流程

首先我們來梳理一下實現(xiàn)這樣一個應(yīng)用需要那些基本功能和步驟。就基本原理而言,我們使用基于特征的匹配方式來比對目標(biāo)模型和現(xiàn)實場景,因此整體流程分為以下幾個步驟:

  1. 對目標(biāo)物體進行建模,也就是需要對目標(biāo)物體進行特征提取
  2. 使用目標(biāo)物體的特征模型訓(xùn)練匹配器
  3. 使用訓(xùn)練好的匹配器將目標(biāo)模型與實時場景進行匹配、計算目標(biāo)物體在場景內(nèi)的姿態(tài)
  4. 根據(jù)匹配結(jié)果繪制對應(yīng)的AR圖形

從上述四個步驟可以看出,整個應(yīng)用的功能可以按階段劃分為訓(xùn)練階段匹配階段
在開始著手實現(xiàn)這兩個階段的具體功能前,我們先來準(zhǔn)備一個模式數(shù)據(jù)類作為存放基礎(chǔ)數(shù)據(jù)的載體,順便簡單講解一下接下來的實現(xiàn)中涉及到的一些基本概念。

實現(xiàn)模式數(shù)據(jù)類Pattern

模式數(shù)據(jù)類用于存放各類基礎(chǔ)模式數(shù)據(jù)。之后的實現(xiàn)中,目標(biāo)物體訓(xùn)練集的結(jié)果,將以這個數(shù)據(jù)類為載體進行存儲。其聲明如下:

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

using namespace cv;
using namespace std;

class Pattern
{
  // 省略構(gòu)造方法和getter/setter
  ...

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

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

可以看到模式數(shù)據(jù)類中,除了作為輔助信息的圖像大小和原始圖像外,還包含下面幾種重要的數(shù)據(jù):

  • 特征點數(shù)組(key points)和其對應(yīng)的描述符(descriptors):基于特征的匹配方式最基礎(chǔ)的數(shù)據(jù)。特征點用于標(biāo)識目標(biāo)物體相對于一般場景最顯著的差異位置,而描述符則是對這些差異的具體描述。
  • 描述目標(biāo)物體邊界的2D和3D點集(m_points2d和m_points3d)

簡單總結(jié)就是,通過邊界限定+特征點和描述的集合來實現(xiàn)對目標(biāo)物體的建模。

完成了基礎(chǔ)模式數(shù)據(jù)類的設(shè)計后,我們來根據(jù)上面整理的兩個階段,規(guī)劃下需要進一步實現(xiàn)的其他工具類??偨Y(jié)起來,我們需要實現(xiàn)下面幾個功能:

  • 對目標(biāo)物體的建模
  • 在檢測場景中檢測目標(biāo)物體
  • 目標(biāo)物體的姿態(tài)估計和繪制

簡明起見,我們將前兩個功能整合到統(tǒng)一的模式檢測器(PatternDetector)工具類中,最后一個功能因為涉及到界面繪制,我們將它分離出來單獨實現(xiàn)一個模式跟蹤器(PatternTracker),并通過組合的方式將這三類功能串起來,統(tǒng)一由模式檢測器向操作界面提供功能接口。

模式檢測器的聲明如下,其具體的方法會在后文詳細(xì)講解,這里我們先只關(guān)注其中比較重要的成員變量:

#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
  
  // 省略構(gòu)造和析構(gòu)方法
  ...

  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;

    // 目標(biāo)模型模式數(shù)據(jù)(訓(xùn)練集)
    Ptr<Pattern> m_pattern;

    // 查詢集
    Mat m_queryDescriptors;
    
    // 單應(yīng)變換矩陣(緩存用)
    Mat m_homography;

};

上面的聲明中,除了包含組合了訓(xùn)練集和查詢集的基礎(chǔ)數(shù)據(jù)外,還組合了OpenCV提供的工具特征檢測器描述符匹配器,這兩個工具的具體使用我們放到后文講到對應(yīng)的處理流程時再詳細(xì)說明。而
上文所述的模式跟蹤器聲明如下:

#include <opencv2/core.hpp>

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

using namespace cv;
using namespace std;

class PatternTracker : public QObject
{
    Q_OBJECT
 
  // 省略構(gòu)造、析構(gòu)和gettter/setter方法
  ...

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

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

也就是提供了繪制目標(biāo)物體2D輪廓和3D姿態(tài)AR圖像的方法。需要特別說明的是,這里引用QCvCamera.h頭文件是為了使用我們在第五小節(jié)實現(xiàn)的標(biāo)定數(shù)據(jù)管理功能。

接下來我們就分別來詳細(xì)看看如何使用這些工具類實現(xiàn)訓(xùn)練和檢測階段所需要的各類功能。

訓(xùn)練階段

首先簡單講解一下我們對幾個關(guān)鍵組件,也就是特征檢測器描述符匹配器類型的選擇:

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

可以看到我們選取了SURF特征及其對應(yīng)的特征檢測器,而匹配器我們選擇了基于FLANN的匹配器,以實現(xiàn)快速近似臨近搜索。有關(guān)SURF特征值及FLANN的具體原理和特性,限于篇幅就不在這里展開說明了,有興趣的話可以參考文末的參考鏈接[1]、[2][3]

而使用這兩個組件進行訓(xùn)練的過程實現(xiàn)如下:

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. 從訓(xùn)練圖像中生成模式數(shù)據(jù)(generatePattern)
  2. 使用模式數(shù)據(jù)中提取好的描述符訓(xùn)練匹配器
  3. 將提取得到的關(guān)鍵點繪制在訓(xùn)練圖像上供展示

這其中最關(guān)鍵的生成模式數(shù)據(jù)方法實現(xiàn)如下:

Ptr<Pattern> PatternDetector::generatePattern(const Mat& img)
{
    // 轉(zhuǎn)化灰度圖像
    Mat grayImg;
    if (img.type() == CV_8UC1)
    {
        grayImg = img.clone();
    }
    else
    {
        cvtColor(img, grayImg, cv::COLOR_BGR2GRAY);
    }
    // 提取訓(xùn)練集特征
    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);
    
    // 生成模式數(shù)據(jù)對象
    cv::Size imgSize = Size(img.cols, img.rows);
    cv::Mat origImg = img.clone();
    return Ptr<Pattern>(new Pattern(imgSize, origImg,
                                    keypoints, descriptors,
                                    points2d, points3d));
}

這其中涉及到的主要步驟在上面的注釋中已經(jīng)寫了,就不再贅述了。需要說明的是,因為我們的實際場景是對書本封面的建模,所以這里計算模型邊界的過程很簡單,就是講書本封面這個平面的2D和3D坐標(biāo)計算出來就可以了,而對于3D坐標(biāo)系,我們將坐標(biāo)原點設(shè)置在了書本封面的中心點,比例尺則簡單做了一下歸一化計算。

上面的步驟中涉及到的提取特征過程放在了另一個方法extractFeature中實現(xiàn),具體如下:

void PatternDetector::extractFeature(const Mat& img, vector<KeyPoint>& keypoints, Mat& descriptors)
{
    m_detector->detect(img, keypoints);
    // 過濾重復(fù)的關(guān)鍵點,并僅保留特征最明顯的500個點
    KeyPointsFilter::removeDuplicated(keypoints);
    KeyPointsFilter::retainBest(keypoints, 500);
    // 計算每個關(guān)鍵點的描述符
    m_detector->compute(img, keypoints, descriptors);
}

可以看到OpenCV提供的檢測器工具除了提供基本的特征檢測和描述符計算功能外,還很提供了包括剔除重復(fù)點、設(shè)置保留個數(shù)在內(nèi)的便捷功能。

這樣整個訓(xùn)練階段的實現(xiàn)就完成了,接下來我們來看一看相對復(fù)雜一些的匹配階段。

匹配階段

如前文所述,整個匹配階段分為對目標(biāo)物體模式的匹配和場景中匹配到的模式姿態(tài)估計兩部分,接下來分別就這兩個步驟進行說明。

模式匹配

模式匹配過程通過下面的方法實現(xiàn):

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;
        // 計算匹配結(jié)果
        getMatches(m_queryDescriptors, matches);
        ...
        // 省略單應(yīng)計算內(nèi)容,后文詳述
    }
    else
    {
        return false;
    }
}

在上面的實現(xiàn)中,我們首先使用上文介紹過的extractFeature方法提取場景中的特征形成查詢集,之后通過getMatches方法計算訓(xùn)練集與查詢集的匹配度。getMatches方法具體實現(xiàn)如下:

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);
        }
    }
}

這里的實現(xiàn)我們采用了knn查找作為基本算法,并通過限制最佳匹配與次佳匹配比的閾值來剔除匹配中可能出現(xiàn)的離散值(false negative)。

模式姿態(tài)估計

在完成場景中目標(biāo)模式的匹配后,接下來需要計算模式在場景中的姿態(tài)。對此,首先我們要計算出訓(xùn)練集和查詢集中關(guān)鍵點集的單應(yīng)性,實現(xiàn)如下:

    // 計算單應(yīng)性矩陣并細(xì)化
    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方法實現(xiàn)如下:

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;
}

也就是從匹配結(jié)果中分別取出訓(xùn)練集和查詢集的點id,并通過OpenCV的findHomography方法計算出單應(yīng)性矩陣。而計算出的單應(yīng)性矩陣,可以通過下面的方法進行進一步的細(xì)化:

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;
    }
}

這里的基本原理是:利用計算得到的單應(yīng)性矩陣,將場景中匹配到的目標(biāo)模式進行反向透視投影,并與模式模型中的特征進行比對,從而實現(xiàn)單應(yīng)矩陣的細(xì)化。
上面的實現(xiàn)中,反向透視投影的計算通過OpenCV提供的warpPerspective方法完成。

完成了單應(yīng)矩陣的計算和細(xì)化后,我們就可以通過OpenCV的perspectiveTransform方法計算目標(biāo)模式在場景中對應(yīng)的邊界點位置,并計算結(jié)果提供給模式跟蹤器了。

最后,模式的姿態(tài)估計方法實現(xiàn)如下:

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方法,計算出目標(biāo)模式在場景中的旋轉(zhuǎn)(rotation)和平移(translation),并將結(jié)果存儲到模式跟蹤器中。

從上面的實現(xiàn)中可以看到,我們將相機的內(nèi)參(CameraIntrinsic)作為入?yún)魅肓诉M來參與了solvePnP的計算。有關(guān)相機內(nèi)參的獲取,可以參考第七小節(jié)的相關(guān)說明。

而模式跟蹤器得到這些計算好的結(jié)果后,只需要借助OpenCV和Qt提供的各類繪制功能將結(jié)果展現(xiàn)在圖像上就可以了,例如下面的繪制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);
}

因為上面的計算結(jié)果大多使用OpenCV提供的數(shù)據(jù)結(jié)構(gòu)類存儲,因此這里為方便使用這些數(shù)據(jù),也使用了OpenCV提供的繪制工具進行繪制。之后在界面上展示時,使用我們前面幾講中實現(xiàn)好的轉(zhuǎn)換工具轉(zhuǎn)換為Qt支持的圖像格式即可。
至此我們的各類核心功能就實現(xiàn)完成了,接下來我們來實現(xiàn)操作界面將這些功能整合起來。

實現(xiàn)操作界面

依照慣例,首先我們實現(xiàn)連接視頻流控件和具體功能的濾波器類:

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);
            }
        }
    }
}

經(jīng)過上面工具類的封裝,這里的實現(xiàn)就相對比較單純了。首先通過模式跟蹤器的模式匹配入口findPatternFromScene方法檢測目標(biāo)模式是否在場景中,然后判斷是否已加載了相機內(nèi)參數(shù)據(jù),如果加載了就繪制3D姿態(tài)方塊,否則只繪制2D輪廓。
實現(xiàn)了濾波器后,我們最后來著手實現(xiàn)最終的圖形界面,UI設(shè)計如下:


book_gui.png

除了左側(cè)的展示區(qū)外,還包含了加入相機內(nèi)參數(shù)據(jù)的按鈕、加載訓(xùn)練圖片和訓(xùn)練按鈕,以及開始匹配跟蹤的按鈕。
各個按鈕的詳細(xì)實現(xiàn)限于篇幅就不詳細(xì)貼了,這里只說明一下比較關(guān)鍵的幾個實現(xiàn)步驟:

  • 相機內(nèi)參數(shù)據(jù)讀取:只需要獲取內(nèi)參文件的文件名,然后調(diào)用QCvCamView封裝好的updateCalibrarion方法即可
  • 訓(xùn)練模式模型:調(diào)用模式檢測器的train方法即可
  • 開始匹配跟蹤:初始化視頻流控件時添加上面實現(xiàn)好的濾波器,在點擊開始按鈕時開啟視頻流即可

測試跟蹤效果

最終實現(xiàn)完成的測試效果如下:

  • 加載訓(xùn)練圖片并訓(xùn)練模式模型,結(jié)果如下:


    book_train.png

    其中紅色的十字即為訓(xùn)練時檢測到的特征點。

  • 在沒有加載相機內(nèi)參時,匹配跟蹤效果如下:


    book2D.png
  • 加載了相機內(nèi)參后,匹配跟蹤結(jié)果如下:


    book_track.png

那么關(guān)于這個相對復(fù)雜的無標(biāo)記AR范例就先講解到這里。
>>本篇參考代碼
>>返回系列索引

參考鏈接

[1] SURF Wikipedia
[2] FLANN官網(wǎng)
[3] OpenCV Flann匹配教程
[4] 圖像單應(yīng)性Wikipedia

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容