OpenCV人脸识别的原理 (原文完整版)

http://www.educity.cn/wenda/358439.html

“人脸识别”是一个在计算机视觉和生物特征识别领域十分活跃的话题。这个主题已经被给力地研究了25年,并且最终在安全、机器人学、人机交互、数码摄像机、游戏和娱乐领域得到了广泛应用。

  “人脸识别”大致可分为两个阶段:

  1.人脸检测 搜索一幅图像,寻找一切人脸区域(此处以绿色矩形显示),然后进行图像处理,清理脸部图像以便于更好地识别。

  2.人脸识别 把上一阶段检测处理得到的人脸图像与数据174x197库中的已知人脸进行比对,判定人脸对应的人是谁(此处以红色文本显示)。

  2002年后,人脸检测已经可以相当可靠地运作。比如OpenCV的Face Detector,对于一个人直视摄像头得到的较清晰图片,大约有90-95%的准确度。通常来说,当人以侧面对准摄像头或与摄像头成一定角度时,较难检测到人脸,有时需要3D Head Pose Estimation。假如图片亮度不是很好,也较难检测到人脸。脸部的部分区域比另一部分区域明亮,带有阴影,模糊,或者戴眼镜,也会影响检测效果。

  然而,人脸识别却比人脸检测不可靠得多,一般只有30-70%的准确度。20世纪90年代以来,人脸识别一直是一个很重要的研究领域,但仍然十分不可靠,并且每一年都有更多的识别技术被创造,如文章底部所列出的(Alternatives to Eigenfaces such as 3D face recognition or recognition from video.)

  我将向你展示如何使用“特征脸”(Eigenfaces),也称为主元分析法(Principal Component Analysis or PCA)。相对于普通的神经网络方法(Neural Networks)和Fisher Faces方法来说,这是一个简单和流行的对图片进行的二维人脸识别的方法。

  要学习特征脸方法的理论,你需要阅读Face Recognition With Eigenface from Servo Magazine (April 2007),可能还需要一些数学算法。

  首先我将向你解释,怎样实现特征脸的命令行离线训练(offline training from the command-line),基于Servo Magazine tutorial and source-code (May 2007)。

  之后,我将说明如何将此扩展成为从网络摄像头进行实时的在线训练:-)

  使用OpenCV的Face Detector检测人脸

  如上所述,人脸识别的第一个阶段是人脸检测。OpenCV库使得使用它的Haar Cascade Face Detector(也称为Viola-Jones方法)检测正面人脸变得相当简单。

  OpenCV里的“cvHaarDetectObjects”函数执行人脸检测,但是这个函数直接用没有意义,所以最好用这个包装好的函数:

  // Perform face detection on the input image, using the given Haar Cascade. // Returns a rectangle for the detected region in the given image. CvRect detectFaceInImage(IplImage *inputImg, CvHaarClassifierCascade* cascade) { // Smallest face size. CvSize minFeatureSize = cvSize(20, 20); // Only search for 1 face. int flags = CV_HAAR_FIND_BIGGEST_OBJECT | CV_HAAR_DO_ROUGH_SEARCH; // How detailed should the search be. float search_scale_factor = 1.1f; IplImage *detectImg; IplImage *greyImg = 0; CvMemStorage* storage; CvRect rc; double t; CvSeq* rects; CvSize size; int i, ms, nFaces; storage = cvCreateMemStorage(0); cvClearMemStorage( storage ); // If the image is color, use a greyscale copy of the image. detectImg = (IplImage*)inputImg; if (inputImg->nChannels > 1) { size = cvSize(inputImg->width, inputImg->height); greyImg = cvCreateImage(size, IPL_DEPTH_8U, 1 ); cvCvtColor( inputImg, greyImg, CV_BGR2GRAY ); detectImg = greyImg; // Use the greyscale image. } // Detect all the faces in the greyscale image. t = (double)cvGetTickCount(); rects = cvHaarDetectObjects( detectImg, cascade, storage, search_scale_factor, 3, flags, minFeatureSize); t = (double)cvGetTickCount() - t; ms = cvRound( t / ((double)cvGetTickFrequency() * 1000.0) ); nFaces = rects->total; printf("Face Detection took %d ms and found %d objectsn", ms, nFaces); // Get the first detected face (the biggest). if (nFaces > 0) rc = *(CvRect*)cvGetSeqElem( rects, 0 ); else rc = cvRect(-1,-1,-1,-1); // Couldn't find the face. if (greyImg) cvReleaseImage( &greyImg ); cvReleaseMemStorage( &storage ); //cvReleaseHaarClassifierCascade( &cascade ); return rc; // Return the biggest face found, or (-1,-1,-1,-1). } // Perform face detection on the input image, using the given Haar Cascade. // Returns a rectangle for the detected region in the given image. CvRect detectFaceInImage(IplImage *inputImg, CvHaarClassifierCascade* cascade) { // Smallest face size. CvSize minFeatureSize = cvSize(20, 20); // Only search for 1 face. int flags = CV_HAAR_FIND_BIGGEST_OBJECT | CV_HAAR_DO_ROUGH_SEARCH; // How detailed should the search be. float search_scale_factor = 1.1f; IplImage *detectImg; IplImage *greyImg = 0; CvMemStorage* storage; CvRect rc; double t; CvSeq* rects; CvSize size; int i, ms, nFaces; storage = cvCreateMemStorage(0); cvClearMemStorage( storage ); // If the image is color, use a greyscale copy of the image. detectImg = (IplImage*)inputImg; if (inputImg->nChannels > 1) { size = cvSize(inputImg->width, inputImg->height); greyImg = cvCreateImage(size, IPL_DEPTH_8U, 1 ); cvCvtColor( inputImg, greyImg, CV_BGR2GRAY ); detectImg = greyImg; // Use the greyscale image. } // Detect all the faces in the greyscale image. t = (double)cvGetTickCount(); rects = cvHaarDetectObjects( detectImg, cascade, storage, search_scale_factor, 3, flags, minFeatureSize) t = (double)cvGetTickCount() - t; ms = cvRound( t / ((double)cvGetTickFrequency() * 1000.0) ); nFaces = rects->total; printf("Face Detection took %d ms and found %d objectsn", ms, nFaces); // Get the first detected face (the biggest). if (nFaces > 0) rc = *(CvRect*)cvGetSeqElem( rects, 0 ); else rc = cvRect(-1,-1,-1,-1); // Couldn't find the face. if (greyImg) cvReleaseImage( &greyImg ); cvReleaseMemStorage( &storage ); //cvReleaseHaarClassifierCascade( &cascade ); return rc; // Return the biggest face found, or (-1,-1,-1,-1). }



  现在如果你想要在一张图片里寻找人脸,只需要简单地调用“detectFaceInImage”函数。你也需要指定OpenCV使用的人脸分类器(Face Classifier)。比如,OpenCV自带了一些用于正面脸的分类器,也有一些用于侧面脸的,还有眼睛检测,鼻检测,嘴检测,全身检测等等。你实际上可以任意把其它的分类检测器用于此函数,甚至创造你自己定制的分类检测器,比如车或人的检测(阅读此处),但既然正脸检测是唯一十分可靠的,这将是我们唯一要讨论的。

  对于正面人脸检测,你应该选取这些OpenCV自带的haar级联分类器(Haar Cascade Classifiers,in the “datahaarcascades” folder)。

  • “haarcascade_frontalface_default.xml”
  • “haarcascade_frontalface_alt.xml”
  • “haarcascade_frontalface_alt2.xml”
  • “haarcascade_frontalface_alt_tree.xml”

  每个haar级联分类器都将给出略微不同的结果,这依赖于你的环境因素,所以你甚至可以用全部分类器,把结果结合在一起(如果你想要做尽可能多地检测)。有一些更多的用于眼睛,头部,嘴巴,鼻子的分类器在Modesto’s page下载。

  你可以在你的程序里这样做来进行人脸检测:

  // Haar Cascade file, used for Face Detection. char *faceCascadeFilename = "haarcascade_frontalface_alt.xml"; // Load the HaarCascade classifier for face detection. CvHaarClassifierCascade* faceCascade; faceCascade = (CvHaarClassifierCascade*)cvLoad(faceCascadeFilename, 0, 0, 0); if( !faceCascade ) { printf("Couldnt load Face detector '%s'n", faceCascadeFilename); exit(1); } // Grab the next frame from the camera. IplImage *inputImg = cvQueryFrame(camera); // Perform face detection on the input image, using the given Haar classifier CvRect faceRect = detectFaceInImage(inputImg, faceCascade); // Make sure a valid face was detected. if (faceRect.width > 0) { printf("Detected a face at (%d,%d)!n", faceRect.x, faceRect.y); } .... Use 'faceRect' and 'inputImg' .... // Free the Face Detector resources when the program is finished cvReleaseHaarClassifierCascade( &cascade );

  对脸部图像进行预处理以便于识别

  现在你已经检测到一张人脸,你可以使用那张人脸图片进行人脸识别。然而,假如你尝试这样简单地从一张普通图片直接进行人脸识别的话,你将会至少损失10%的准确率!

  在一个人脸识别系统中,应用多种预处理技术对将要识别的图片进行标准化处理是极其重要的。多数人脸识别算法对光照条件十分敏感,所以假如在暗室训练,在明亮的房间就可能不会被识别出来等等。这个问题可归于“lumination dependent”,并且还有其它很多例子,比如脸部也应当在图片的一个十分固定的位置(比如眼睛位置为相同的像素坐标),固定的大小,旋转角度,头发和装饰,表情(笑,怒等),光照方向(向左或向上等),这就是在进行人脸识别前,使用好的图片预处理过滤器十分重要的原因。你还应该做一些其它事情,比如去除脸部周围的多余像素(如用椭圆遮罩,只显示其内部的人脸区域而不是头发或图片背景,因为他们的变化多于脸部区域)。

  为简单起见,我展示给你的人脸识别系统是使用灰度图像的特征脸方法。所以我将向你说明怎样简单地把彩色图像转化为灰度图像,并且之后简单地使用直方图均衡化(Histogram Equalization)作为一种自动的标准化脸部图像亮度和对比度的方法。为了得到更好的结果,你可以使用彩色人脸识别(color face recognition,ideally with color histogram fitting in HSV or another color space instead of RGB),或者使用更多的预处理,比如边缘增强(edge enhancement),轮廓检测(contour detection),手势检测(motion detection),等等。这份代码把图片调整成一个标准的大小,但是可能会改变脸的纵横比(aspect ratio)。你可以阅读我这里的教程HERE,来了解怎样调整图像大小而不改变它的纵横比。

  你可以看到一个预处理阶段的例子:

  343x96

  这是把一幅RGB格式的图像或灰度图像转变为灰度图像的基本代码。它还把图像调整成了固定的维度,然后应用直方图均衡化来实现固定的亮度和对比度。

  // Either convert the image to greyscale, or use the existing greyscale image. IplImage *imageGrey; if (imageSrc->nChannels == 3) { imageGrey = cvCreateImage( cvGetSize(imageSrc), IPL_DEPTH_8U, 1 ); // Convert from RGB (actually it is BGR) to Greyscale. cvCvtColor( imageSrc, imageGrey, CV_BGR2GRAY ); } else { // Just use the input image, since it is already Greyscale. imageGrey = imageSrc; } // Resize the image to be a consistent size, even if the aspect ratio changes. IplImage *imageProcessed; imageProcessed = cvCreateImage(cvSize(width, height), IPL_DEPTH_8U, 1); // Make the image a fixed size. // CV_INTER_CUBIC or CV_INTER_LINEAR is good for enlarging, and // CV_INTER_AREA is good for shrinking / decimation, but bad at enlarging. cvResize(imageGrey, imageProcessed, CV_INTER_LINEAR); // Give the image a standard brightness and contrast. cvEqualizeHist(imageProcessed, imageProcessed); ..... Use 'imageProcessed' for Face Recognition .... if (imageGrey) cvReleaseImage(&imageGrey); if (imageProcessed) cvReleaseImage(&imageProcessed);



  把“特征脸”用于人脸识别

  现在你已经有了一张经过预处理后的脸部图片,你可以使用特征脸(PCA)进行人脸识别。OpenCV自带了执行PCA操作的”cvEigenDecomposite()”函数,然而你需要一个图片数据库(训练集)告诉机器怎样识别当中的人。

  所以你应该收集每个人的一组预处理后的脸部图片用于识别。比如,假如你想要从10人的班级当中识别某个人,你可以为每个人存储20张图片,总共就有200张大小相同(如100×100像素)的经预处理的脸部图片。

  特征脸的理论在Servo Magazine的两篇文章(Face Recognition with Eigenface)中解释了,但我仍会在这里尝试着向你解释。

  我们使用“主元分析”把你的200张训练图片转换成一个代表这些训练图片主要区别的“特征脸”集。首先它将会通过获取每个像素的平均值,生成这些图片的“平均人脸图片”。然后特征脸将会与“平均人脸”比较。第一个特征脸是最主要的脸部区别,第二个特征脸是第二重要的脸部区别,等等……直到你有了大约50张代表大多数训练集图片的区别的特征脸。

  130x150130x150130x150

  在上面这些示例图片中你可以看到平均人脸和第一个以及最后一个特征脸。它们是从一个四人的每人30幅图片的训练集中生成的。注意到,平均人脸显示的是一个普通人的平滑脸部结构,排在最前的一些特征脸显示了一些主要的脸部特征,而最后的特征脸(比如Eigenface 119)主要是图像噪声。你可以在下面看到前32张特征脸。

  532x307

  使用主元分析法进行人脸识别

  简单地说,特征脸方法(Principal Component Analysis)计算出了训练集中图片的主要区别,并且用这些“区别”的组合来代表每幅训练图片。

  比如,一张训练图片可能是如下的组成:

  (averageFace) + (13.5% of eigenface0) – (34.3% of eigenface1) + (4.7% of eigenface2) + … + (0.0% of eigenface199).

  一旦计算出来,就可以认为这张训练图片是这200个比率(ratio):

  {13.5, -34.3, 4.7, …, 0.0}.

  用特征脸图片分别乘以这些比率,并加上平均人脸图片 (average face),从这200个比率还原这张训练图片是完全可以做到的。但是既然很多排在后面的特征脸是图像噪声或者不会对图片有太大作用,这个比率表可以被降低到只剩下最主要的,比如前30个,不会对图像质量有很大影响。所以现在可以用30个特征脸,平均人脸图片,和一个含有30个比率的表,来代表全部的200张训练图片。

  有趣的是,这意味着,我们已经找到了一种方法把200张图片压缩成31张图片再加上一点点数据,而不丢失很多的图像质量。但是这个教程是关于人脸识别的,而不是图像压缩的,所以我们将会忽略它:-)

  在另一幅图片中识别一个人,可以应用相同的PCA计算,使用相同的200个特征脸来寻找200个代表输入图片的比率。并且仍然可以只保留前30个比率而忽略其余的比率,因为它们是次要的。然后通过搜索这些比率的表,寻找在数据库中已知的20个人,来看谁的前30个比率与输入图片的前30个比率最接近。这就是寻找与输入图片最相似的训练图片的基本方法,总共提供了200张训练图片。

  离线命令行训练的实现

  为了实现离线训练,也就是通过命令行(command-line)使用文件作为输入输出,我使用了与Servo Magazine里Face Recognition with Eigenface相同的实现,所以你可以先阅读这篇文章,但是我做了一些小的改动。

  基本上,从训练图片创建一个人脸识别数据库,就是创建一个列出图片文件和每个文件代表的人的文本文件。

  比如,你可以把这些输入一个名为”4_images_of_2_people.txt”的文本文件:

  1 Shervin dataShervinShervin1.bmp 1 Shervin dataShervinShervin2.bmp 1 Shervin dataShervinShervin3.bmp 1 Shervin dataShervinShervin4.bmp 2 Chandan dataChandanChandan1.bmp 2 Chandan dataChandanChandan2.bmp 2 Chandan dataChandanChandan3.bmp 2 Chandan dataChandanChandan4.bmp

  它告诉这个程序,第一个人的名字叫“Shervin”,而Shervin的四张预处理后的脸部图像在”dataShervin”文件夹,第二个人的名字叫”Chandan”,在”dataChandan”中有她的四张图片。这个程序可以使用”loadFaceImgArray()”函数把这些图片加载到一个图片数组中。注意,为了简单起见,它不允许空格或特殊字符出现在人名中,< >所以你可能想要实现这一功能,或者把人名中的空格用下划线代替(比如 Shervin_Emami)。

  为了从这些加载好的图片中创建一个数据库,你可以使用OpenCV的”cvCalcEigenObjects()”和”cvEigenDecomposite()”函数,比如:

  // Tell PCA to quit when it has enough eigenfaces. CvTermCriteria calcLimit = cvTermCriteria( CV_TERMCRIT_ITER, nEigens, 1); // Compute average image, eigenvectors (eigenfaces) and eigenvalues (ratios). cvCalcEigenObjects(nTrainFaces, (void*)faceImgArr, (void*)eigenVectArr, CV_EIGOBJ_NO_CALLBACK, 0, 0, &calcLimit, pAvgTrainImg, eigenValMat->data.fl); // Normalize the matrix of eigenvalues. cvNormalize(eigenValMat, eigenValMat, 1, 0, CV_L1, 0); // Project each training image onto the PCA subspace. CvMat projectedTrainFaceMat = cvCreateMat( nTrainFaces, nEigens, CV_32FC1 ); int offset = projectedTrainFaceMat->step / sizeof(float); for(int i=0; i<nTrainFaces; i++) { cvEigenDecomposite(faceImgArr[i], nEigens, eigenVectArr, 0, 0, pAvgTrainImg, projectedTrainFaceMat->data.fl + i*offset); }



  现在你有了:

  • 平均人脸图片”pAvgTrainImg”,
  • 包含特征脸图片的数组”eigenVectArr[]“(如:假如你使用了nEigens=200 张训练图片,将得到200 个特征脸),
  • 特征值矩阵 (即特征脸的比率,eigenface ratios) 每张图片的”projectedTrainFaceMat” 。

  现在这些可以被储存成一个文件,也就是人脸识别的数据库。代码中的”storeTrainingData()”函数将会把这些数据储存到”facedata.xml“文件里,它可以随时被重新载入来识别经训练过的人。代码中也有一个”storeEigenfaceImages()”的函数,生成前面提到的图片,平均人脸图片被保存到”out_averageImage.bmp”,特征脸被保存到”out_eigenfaces.bmp”。

  离线命令行识别的实现

  在离线训练阶段,系统尝试从一个文本文件中的列表读取若干张图像中的人脸,并进行识别。为了实现它,我仍然使用Servo Magazine的Face Recognition with Eigenface的实现,在此基础上扩展。

  用于离线训练的相同格式的文本文件也可用于离线识别。这个文本文件列出了用于测试的图像文件和对应于这张图像的正确的人名。随后这个程序就对每一幅图片进行识别,并且检验文本文件中的真实值(图片对应的正确人名)来确认其是否识别正确,并统计它的准确率。

  离线识别的实现几乎与离线训练完全相同:

  1. 读取原来的用于训练的文本文件(现在用于识别),把若干个图片文件(预处理后的脸部图片)和名字载入一个图片数组。这些在代码中用“loadFaceImgArray()”函数执行。

  2. 平均人脸,特征脸和特征值(比率)使用函数“loadTrainingData()” 从人脸识别数据库文件(the face recognition database fil)“facedata.xml”载入。

  3. 使用OpenCV的函数“cvEigenDecomposite()”,每张输入的图片都被投影到PCA子空间,来观察哪些特征脸的比率最适合于代表这张图片。

  4. 现在有了特征值(特征脸图片的比率)代表这张输入图片,程序需要查找原始的训练图片,找出拥有最相似比率的图片。这些用数学的方法在“findNearestNeighbor()”函数中执行,采用的是“欧几里得距离(Euclidean Distance)”,但是它只是基本地检查输入图片与每张训练图片的相似性,找到最相似的一张:一张在欧几里得空间上与输入图片距离最近的图片。就像在 Servo Magazine的文章上提到的那样,如果使用马氏距离( the Mahalanobis space,需要在代码里定义 USE_MAHALANOBIS_DISTANCE),你可以得到更准确的结果。

  5. 在输入图片与最相似图片之间的距离用于确定可信度(confidence),作为是否识别出某人的指导。1.0的可信度意味着完全相同,0.0或者负的可信度意味着非常不相似。但是需要注意,我在代码中用到的可信度公式只是一个非常基本的可信度测量,不是很可靠,但是我觉得多数人会想要看到一个粗略的可信度值。你可能发现它对你的图片给出错误的值,所以你可以禁用它(比如:把可信度设为恒定的1.0)。

  一旦指导哪张训练图片和输入图片最相似,并假定可信度值不是太低(应该至少是0.6或更高),那么它就指出了那个人是谁,换句话说,它识别出了那个人!

  摄像头实时识别的实现

  要让一个摄像头视频流输入取代文件列表是十分简单的。基本上,你只要从摄像头抓取一帧,而不是读取一个文件,并且一直运行下去直到用户退出,而不是等待文件读取到头就行了。OpenCV为此提供了“cvCreateCameraCapture()”函数(或cvCaptureFromCAM())。

  从摄像头抓取一帧可以简单地用下面的函数实现:

  // Grab the next camera frame. Waits until the next frame is ready, and // provides direct access to it, so do NOT modify or free the returned image! // Will automatically initialize the camera on the first frame. IplImage* getCameraFrame(CvCapture* &camera) { IplImage *frame; int w, h; // If the camera hasn't been initialized, then open it. if (!camera) { printf("Acessing the camera ...\n"); camera = cvCreateCameraCapture( 0 ); if (!camera) { printf("Couldn't access the camera.\n"); exit(1); } // Try to set the camera resolution to 320 x 240. cvSetCaptureProperty(camera, CV_CAP_PROP_FRAME_WIDTH, 320); cvSetCaptureProperty(camera, CV_CAP_PROP_FRAME_HEIGHT, 240); // Get the first frame, to make sure the camera is initialized. frame = cvQueryFrame( camera ); if (frame) { w = frame->width; h = frame->height; printf("Got the camera at %dx%d resolution.\n", w, h); } // Wait a little, so that the camera can auto-adjust its brightness. Sleep(1000); // (in milliseconds) } // Wait until the next camera frame is ready, then grab it. frame = cvQueryFrame( camera ); if (!frame) { printf("Couldn't grab a camera frame.\n"); exit(1); } return frame; }



  这个函数可以这样用:

  CvCapture* camera = 0; // The camera device. while ( cvWaitKey(10) != 27 ) { // Quit on "Escape" key. IplImage *frame = getCameraFrame(camera); ... } // Free the camera. cvReleaseCapture( &camera );



  请注意,假如你是为windows操作系统开发,你可以使用 Theo Watson 的 videoInput Library v0.1995 达到两倍于这些代码的速度。它使用了DirectShow硬件加速,然而OpenCV使用VFW已经15年不变了!
把我已经解释的这些部分放到一起,人脸识别系统运行步骤如下:

  1. 从摄像头抓取一帧图片。

  2. 转换彩色图片帧为灰度图片帧。

  3. 检测灰度图片帧的人脸。

  4. 处理图片以显示人脸区域(使用 cvSetImageROI() 和 cvCopyImage())。

  5. 预处理脸部图片。

  6. 识别图片中的人。

  摄像头实时训练的实现

  现在你已经有了一个用摄像头实时识别人脸的方法,但是要学习新人脸,你不得不关闭这个程序,把摄像头的图片保存成图片文件,更新图片列表,使用离线命令行训练的方法,然后以实时摄像头识别的模式再次运行这个程序。所以实际上,你完全可以用程序来执行实时的摄像头训练!

  这里就是用摄像头视频流把一个新的人加入人脸识别数据库而不关闭程序的一个最简单的方法:

  1. 从摄像头收集一些图片(预处理后的脸部图片),也可以同时执行人脸识别。

  2. 用“cvSaveImage()”函数保存这些脸部图片作为图片文件存入磁盘。

  3. 加入每张脸部图片的文件名到训练图片列表(用于离线命令行训练的文本文件)的底部。

  4. 一旦你准备实时训练,你将从所有图片文件形成的数据库重新训练。这个文本文件列出了新加入的训练图片文件,并且这些图片被电脑存为了图片文件,所以实时训练工作起来跟离线训练一样。

  5. 但是在重新训练之前,释放任何正在使用的资源和重新初始化也很必要。应该像你重新启动了这个程序一样。比如,在图片被存储成文件并且加入训练列表的文本文件后,你应该再执行相同的离线训练(包括从训练列表文件载入图片,用PCA方法找出新训练集的特征脸和比率)之前释放特征脸数组。 这个实时训练的方法相当低效,因为假如在训练集中有50个人,而你多加了一个人,它将为51个人重新训练,这是非常不好的,因为训练的时间随着用户或图片数量的增加呈指数级增长。但是假如你只是处理百来张图片,它不需要多少秒就可以完成。

  文件下载请转到原文。

  The article source is 

  • 12
    点赞
  • 144
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值