• 使用OpenCV和Python进行人脸识别


    介绍

      人脸识别是什么?或识别是什么?当你看到一个苹果时,你的大脑会立刻告诉你这是一个苹果。在这个过程中,你的大脑告诉你这是一个苹果水果,用简单的语言来说就是识别。那么什么是人脸识别呢?我肯定你猜对了。当你看着你的朋友走在街上或他的照片时,你会认出他是你的朋友保罗。有趣的是,当你看你的朋友或他的照片时,你首先要看他的脸,然后再看其他东西。你想过为什么要这么做吗?这是为了让你看他的脸就能认出他来。好吧,这是你的面部识别。

      但真正的问题是人脸识别是如何工作的?它非常简单和直观。举一个现实生活中的例子,当你在生活中第一次遇见一个人,你不认识他,对吧?当他和你说话或握手时,你看着他的脸、眼睛、鼻子、嘴巴、颜色和整体的表情。这是你通过收集面部数据来学习或训练那个人的面部识别。然后他告诉你他叫保罗。此时,你的大脑知道它刚刚学到的面部数据属于保罗。现在你的头脑已经训练好了,准备在保罗的脸上做面部识别。下次当你在照片中看到保罗或他的脸时,你会立刻认出他。这就是人脸识别的工作原理。你遇到保罗的次数越多,你的大脑就会收集到更多关于他的信息,尤其是他的脸,你就越能识别他。

      下一个问题是如何用OpenCV编码人脸识别,毕竟这是你阅读这篇文章的唯一原因,对吧?那么,好吧。你可能会说我们的大脑可以很容易地做这些事情,但是把它们编码到电脑里是很困难的吗?别担心,不是的。多亏了OpenCV,编码人脸识别变得越来越容易。人脸识别的编码步骤与我们在上面的实际示例中讨论的一样。

      ·训练数据收集:收集您想要识别的人的面部数据(本例中为面部图像)

      ·识别器的训练:将人脸数据(以及每个人脸的相应名称)输入人脸识别器,使其能够学习。

      ·识别:输入这些人的新面孔,看看你刚训练过的人脸识别器是否识别他们

    OpenCV带有内置的人脸识别器,你所要做的就是给它输入人脸数据。这很简单,一旦我们完成了编码,它就会看起来很简单。

     

    如果您需要这篇文章程序的话,请点击这段话,进行下载!

    OpenCV面部识别器

      OpenCV有三个内置的人脸识别器,多亏了OpenCV干净的编码,你可以通过改变一行代码来使用它们中的任何一个。下面是这些人脸识别器的名称和它们的OpenCV调用

      1、EigenFaces人脸识别器识别器 - cv2.face.createEigenFaceRecognizer()

      2、FisherFaces人脸识别器识别器 - cv2.face.createFisherFaceRecognizer()

       3、局部二值模式直方图(LBPH)人脸识别器 - cv2.face.createLBPHFaceRecognizer()

      现在我们有三个人脸识别器,但是你知道该用哪一个吗?什么时候用吗?或者哪个更好?我猜你不知道。那么,接下来我们将深入研究每一个识别器。

     EigenFaces面部识别器

      这个算法考虑的事实是,并不是脸的所有部分都同样重要,或同样有用。当你看一个人的时候,你会通过他独特的特征认出他/她,比如眼睛、鼻子、脸颊、前额以及他们之间的差异。所以你实际上关注的是最大变化的区域(数学上说,这个变化是方差)。例如,从眼睛到鼻子有一个显著的变化,从鼻子到嘴也是如此。当你看多张脸的时候你可以通过看脸的这些部分来比较它们因为这些部分是脸最有用和最重要的组成部分。重要的是,它们捕捉到人脸之间的最大变化,这种变化可以帮助你区分不同的人脸,这就是特征人脸识别系统的工作原理。

      EignFaces人脸识别器将所有人的训练图像作为一个整体,并试图提取重要和有用的成分(捕捉最大方差/变化的成分),并丢弃其余的成分。这样,它不仅从训练数据中提取重要的组件,而且通过丢弃不重要的组件来节省内存。它提取的这些重要成分被称为主成分。

      我所用主成分,方差,高变化区域,可互换的有用特征等术语,它们的性质基本上是一样的东西。
      以下是显示从面部列表中提取的主要组件的图像。

    主成分

    source

      你可以看到,主分量实际上表示面的,这些面被称为特征面,也就是算法的名字。

      这就是特征面识别器自身的训练方式(通过提取主成分),它还记录了哪个主成分属于哪个人。在上面的图像中需要注意的一点是特征面算法也将光照作为一个重要的组成部分。

      在随后的识别过程中,当你向算法输入新图像时,它也会在该图像上重复同样的过程。它从新映像中提取主组件,并将该组件与它在训练期间存储的组件列表进行比较,并找到匹配最好的组件,并返回与该最佳匹配组件关联的person标签。

      轻松+容易,对吧? 下一个比这个更容易。

    FisherFaces人脸识别器

      该算法是改进后的FisherFaces人脸识别算法。FisherFaces人脸识别器同时查看所有人的训练面,并从所有人的训练面中找到主要的组成部分。通过从所有的人脸中捕获主要的组成部分,你并没有把注意力集中在区分一个人和另一个人的特征上,而是集中在代表整个训练数据中所有人的所有面孔的特征上。

      这种方法有一个缺点。 例如,考虑下面的面光照变化。

      你知道特征面人脸识别器也认为照明是一个重要的组成部分,对吧?想象一个场景,一个人所有的脸都有非常高的亮度变化(非常暗或者非常亮等等)。特征人脸识别者将会考虑这些光照变化非常有用的特征,并且可能会忽略其他人的面部特征,认为这些特征不太有用。现在所提取的特征特征面只代表一个人的面部特征,而不是所有人的面部特征。

      如何解决这个问题? 我们可以通过调整EigenFaces人脸识别器来解决这个问题,以便从每个人的脸部分别提取有用的特征,而不是提取所有脸部组合的有用特征。 这样,即使一个人的光照变化很大,也不会影响其他人物特征提取过程。 这正是FisherFaces人脸识别器算法的功能。

      Fisherfaces算法不是提取表示所有人员所有面部的有用特征,而是提取可区分一个人和另一个人的有用特征。 通过这种方式,一个人的特征不会占据主导地位(被认为是更有用的特征)而其他人则具有区分一个人和另一个人的特征。

    下面是使用Fisherfaces算法提取的特征的图像。

      Fisher Faces

      

       你可以看到提取的特征实际上代表了面孔,这些面被称为Fisher faces,因此算法的名称。

      这里需要注意的一点是,Fisherfaces人脸识别器只会阻止一个人的特征凌驾于另一个人的特征之上,但它仍然认为光照变化是有用的特征。我们知道光照变化不是一个有用的特征来提取,因为它不是真正的脸的一部分。那么,该怎么摆脱这个照明问题?这就是我们的下一个人脸识别器锁解决的问题。

     局部二值模式直方图(LBPH)人脸识别器

      我们知道Eigenfaces和Fisherfaces都受光线影响,在现实生活中,我们无法保证完美的光照条件。 LBPH人脸识别器是克服这个缺点的一种改进。

      这种想法是不看整个图像,而是查找图像的局部特征。 LBPH算法试图找出图像的局部结构,并通过比较每个像素与其相邻像素来实现。

      取一个3x3的窗口,每移动一个图像(图像的每个局部),将中心的像素与相邻像素进行比较。强度值小于或等于中心像素的邻域用1表示,其它邻域用0表示。然后你以顺时针的顺序读取3x3窗口下的0/1值,你会得到一个像11100011这样的二进制模式,这个模式在图像的特定区域是局部的。在整个图像上这样做,就会得到一个局部二进制模式的列表。

    LBP标签

      现在你明白为什么这个算法的名字中有局部二进制模式? 因为你得到一个局部二进制模式列表。 现在你可能想知道,LBPH的直方图部分呢? 在获得局部二进制模式列表后,您可以使用二进制到十进制转换将每个二进制模式转换为十进制数(如上图所示),然后对所有这些十进制值进行直方图制作。 样本直方图是像下面这样的。

      样本直方图

      

      我猜这回答了直方图部分的问题。所以最终你会得到训练数据集中每个人脸图像的一个直方图,这意味着如果训练数据集中有100个图像,那么LBPH会在训练后提取100个直方图,并储存起来以便以后识别。记住,算法也会跟踪哪个直方图属于哪个人。

      在识别后期,当您将新图像送入识别器进行识别时,它将生成新图像的直方图,将该直方图与其已有的直方图进行比较,找到最佳匹配直方图并返回与该最佳匹配关联的人员标签 匹配直方图。

      下面是一张脸和它们各自的局部二进制模式图像的列表。您可以看到,LBP图像不受光照条件变化的影响。

      局部人脸

     

      理论部分已经结束,现在是编码部分!准备好开始编写代码了吗?那我们开始吧。

    使用OpenCV编码人脸识别

      本教程中的人脸识别过程分为三个步骤。

      1、准备训练数据:在这一步中,我们将读取每个人/主体的训练图像及其标签,从每个图像中检测人脸并为每个检测到的人脸分配其所属人员的整数标签。

      2、训练人脸识别器:在这一步中,我们将训练OpenCV的LBPH人脸识别器,为其提供我们在步骤1中准备的数据。

      3、测试:在这一步中,我们会将一些测试图像传递给人脸识别器,并查看它是否能够正确预测它们

     编程工具:

    1. OpenCV 3.2.0.
    2. Python v3.5.
    3. NumPy 

       :Numpy使Python中的计算变得容易。 除此之外,它还包含一个强大的N维数组实现,我们将使用它来将数据作为OpenCV函数的输入。

    导入必需的模块

      在开始实际编码之前,我们需要导入所需的编码模块。 所以让我们先导入它们。

      cv2:是Python的OpenCV模块,我们将用它来进行人脸检测和人脸识别。

      os:我们将使用这个Python模块来读取我们的培训目录和文件名。

      numpy:我们将使用此模块将Python列表转换为numpy数组,因为OpenCV人脸识别器接受numpy数组。

    #导入OpenCV模块
    import cv2
    #导入os模块用于读取训练数据目录和路径
    import os
    # 导入numpy将python列表转换为numpy数组,OpenCV面部识别器需要它
    import numpy as np

    训练数据

      训练中使用的图像越多越好。 通常很多图像用于训练面部识别器,以便它可以学习同一个人的不同外观,例如戴眼镜,不戴眼镜,笑,伤心,快乐,哭泣,留着胡子,没有胡子等。 简单的教程我们将只为每个人使用12张图片。

      所以我们的训练数据由共2人组成,每个人有12张图像。 所有培训数据都在培训数据文件夹内。 训练数据文件夹包含每个人的一个文件夹,并且每个文件夹以格式sLabel(例如s1,s2)命名,其中标签实际上是分配给该人的整数标签。 例如,名为s1的文件夹意味着该文件夹包含人员1的图像。培训数据的目录结构树如下所示:

    training-data
    |-------------- s1
    |               |-- 1.jpg
    |               |-- ...
    |               |-- 12.jpg
    |-------------- s2
    |               |-- 1.jpg
    |               |-- ...
    |               |-- 12.jpg

      测试数据文件夹包含我们将用于在成功培训完成后测试人脸识别器的图像

      由于OpenCV人脸识别器接受标签为整数,因此我们需要定义整数标签和人物实际名称之间的映射,所以下面我定义了人员整数标签及其各自名称的映射。

      注意:由于我们尚未将标签0分配给任何人,因此标签0的映射为空。

    #我们的训练数据中没有标签0,因此索引/标签0的主题名称为空
    subjects = ["", "Ramiz Raja", "Elvis Presley"]

    准备训练数据

      您可能想知道为什么要进行数据准备,对吗? 那么,OpenCV人脸识别器接受特定格式的数据。 它接受两个矢量,一个矢量是所有人的脸部,第二个矢量是每个脸部的整数标签,因此在处理脸部时,脸部识别器会知道该脸部属于哪个人。

      例如,如果我们有两个人和两个图像为每个人。

      PERSON-1    PERSON-2   
    
      img1        img1         
      img2        img2

      然后,准备数据步骤将生成以下面和标签向量。

      FACES                        LABELS
    
      person1_img1_face              1
      person1_img2_face              1
      person2_img1_face              2
      person2_img2_face              2

      准备数据步骤可以进一步分为以下子步骤。

      1、阅读培训数据文件夹中提供的所有主题/人员的文件夹名称。 例如,在本教程中,我们有文件夹名称:s1,s2。

      2、对于每个主题,提取标签号码。 你还记得我们的文件夹有一个特殊的命名约定吗? 文件夹名称遵循格式sLabel,其中Label是一个整数,代表我们已分配给该主题的标签。 因此,例如,文件夹名称s1表示主题具有标签1,s2表示主题标签为2等。 将在此步骤中提取的标签分配给在下一步中检测到的每个面部。

      3、阅读主题的所有图像,从每张图像中检测脸部。

      4、将添加到标签矢量中的具有相应主题标签(在上述步骤中提取)的每个脸部添加到脸部矢量。

    #使用OpenCV用来检测脸部的函数
    def detect_face(img):
        #将测试图像转换为灰度图像,因为opencv人脸检测器需要灰度图像
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        
        #加载OpenCV人脸检测器,我正在使用的是快速的LBP
        #还有一个更准确但缓慢的Haar分类器
        face_cascade = cv2.CascadeClassifier('opencv-files/lbpcascade_frontalface.xml')
    
        #让我们检测多尺度(一些图像可能比其他图像更接近相机)图像
        #结果是一张脸的列表
        faces = face_cascade.detectMultiScale(gray, scaleFactor=1.2, minNeighbors=5);
        
        #如果未检测到面部,则返回原始图像
        if (len(faces) == 0):
            return None, None
        
        #假设只有一张脸,
        #提取面部区域
        (x, y, w, h) = faces[0]
        
        #只返回图像的正面部分
        return gray[y:y+w, x:x+h], faces[0]

       我正在使用OpenCV的LBP人脸检测器。 在第4行,我将图像转换为灰度,因为OpenCV中的大多数操作都是以灰度进行的,然后在第8行使用cv2.CascadeClassifier类加载LBP人脸检测器。 在第12行之后,我使用cv2.CascadeClassifier类'detectMultiScale方法来检测图像中的所有面部。 在第20行中,从检测到的脸部我只挑选第一张脸部,因为在一张图像中只有一张脸部(假设只有一张醒目的脸部)。 由于detectMultiScale方法返回的面实际上是矩形(x,y,宽度,高度),而不是实际的面部图像,所以我们必须从主图像中提取面部图像区域。 所以在第23行我从灰色图像中提取人脸区域并返回人脸图像区域和人脸矩形。

      现在您已经有了一个面部检测器,您知道准备数据的4个步骤,那么您准备好编写准备数据步骤了吗?是吗?让我们来做它。

    #该功能将读取所有人的训练图像,从每个图像检测人脸
    #并将返回两个完全相同大小的列表,一个列表 
    # 每张脸的脸部和另一列标签
    def prepare_training_data(data_folder_path):
        
        #------STEP-1--------
        #获取数据文件夹中的目录(每个主题的一个目录)
        dirs = os.listdir(data_folder_path)
        
        #列表来保存所有主题的面孔
        faces = []
        #列表以保存所有主题的标签
        labels = []
        
        #让我们浏览每个目录并阅读其中的图像
        for dir_name in dirs:
            
            #我们的主题目录以字母's'开头
            #如果有的话,忽略任何不相关的目录
            if not dir_name.startswith("s"):
                continue;
                
            #------STEP-2--------
            #从dir_name中提取主题的标签号
            #目录名称格式= slabel
            #,所以从dir_name中删除字母''会给我们标签
            label = int(dir_name.replace("s", ""))
            
            #建立包含当前主题主题图像的目录路径
            #sample subject_dir_path = "training-data/s1"
            subject_dir_path = data_folder_path + "/" + dir_name
            
            #获取给定主题目录内的图像名称
            subject_images_names = os.listdir(subject_dir_path)
            
            #------STEP-3--------
            #浏览每个图片的名称,阅读图片,
            #检测脸部并将脸部添加到脸部列表
            for image_name in subject_images_names:
                
                #忽略.DS_Store之类的系统文件
                if image_name.startswith("."):
                    continue;
                
                #建立图像路径
                #sample image path = training-data/s1/1.pgm
                image_path = subject_dir_path + "/" + image_name
    
                #阅读图像
                image = cv2.imread(image_path)
                
                #显示图像窗口以显示图像
                cv2.imshow("Training on image...", image)
                cv2.waitKey(100)
                
                #侦测脸部
                face, rect = detect_face(image)
                
                #------STEP-4--------
                #为了本教程的目的
                #我们将忽略未检测到的脸部
                if face is not None:
                    #将脸添加到脸部列表
                    faces.append(face)
                    #为这张脸添加标签
                    labels.append(label)
                
        cv2.destroyAllWindows()
        cv2.waitKey(1)
        cv2.destroyAllWindows()
        
        return faces, labels

    我已经定义了一个函数,它将存储培训主题文件夹的路径作为参数。 该功能遵循上述的4个准备数据子步骤。

    (step--1)在第8行,我使用os.listdir方法读取存储在传递给函数的路径上的所有文件夹的名称作为参数。 在第10-13行,我定义了标签并面向矢量。

    (step--2)之后,我遍历所有主题的文件夹名称以及第27行中每个主题的文件夹名称,我将提取标签信息。 由于文件夹名称遵循sLabel命名约定,所以从文件夹名称中删除字母将给我们分配给该主题的标签。

    (step--3)在第34行,我读取了当前被摄体的所有图像名称,并且在第39-66行中我逐一浏览了这些图像。 在53-54行,我使用OpenCV的imshow(window_title,image)和OpenCV的waitKey(interval)方法来显示当前正在传播的图像。 waitKey(interval)方法将代码流暂停给定的时间间隔(毫秒),我以100ms的间隔使用它,以便我们可以查看100ms的图像窗口。 在第57行,我从当前正在遍历的图像中检测出脸部。

    (step--4)在第62-66行,我将检测到的面和标签添加到它们各自的向量中。

      但是一个函数只能在需要准备的某些数据上调用它时才能做任何事情,对吗? 别担心,我有两张脸的数据。 我相信你至少会认出其中的一个!

    让我们在这些美丽的名人的图像上调用这个函数来准备数据来训练我们的人脸识别器。 下面是一个简单的代码来做到这一点。

    #让我们先准备好我们的训练数据
    #数据将在两个相同大小的列表中
    #一个列表将包含所有的面孔
    #数据将在两个相同大小的列表中
    print("Preparing data...")
    faces, labels = prepare_training_data("training-data")
    print("Data prepared")
    
    #打印总面和标签
    print("Total faces: ", len(faces))
    print("Total labels: ", len(labels))
    Preparing data...
    Data prepared
    Total faces:  23
    Total labels:  23
    
      这可能是最无聊的部分,对吧?别担心,接下来会有有趣的事情发生。现在是时候训练我们自己的人脸识别器了,这样一旦训练它就能识别它所训练的人的新面孔了。读吗?那我们来训练我们的人脸识别器。

    训练人脸识别器

     我们知道,OpenCV配备了三个人脸识别器。

      1、EigenFaces人脸识别器识别器 - cv2.face.createEigenFaceRecognizer()

      2、FisherFaces人脸识别器识别器 - cv2.face.createFisherFaceRecognizer()

      3、局部二值模式直方图(LBPH)人脸识别器 - cv2.face.createLBPHFaceRecognizer()

      我将使用LBPH人脸识别器,但您可以使用您选择的任何人脸识别器。 无论您使用哪个OpenCV的脸部识别器,其代码都将保持不变。 您只需更改一行,即下面给出的面部识别器初始化行。

    #创建我们的LBPH人脸识别器
    face_recognizer = cv2.face.createLBPHFaceRecognizer()
    
    #或者使用EigenFaceRecognizer替换上面的行
    #face_recognizer = cv2.face.createEigenFaceRecognizer()
    
    #或者使用FisherFaceRecognizer替换上面的行
    #face_recognizer = cv2.face.createFisherFaceRecognizer()

      现在我们已经初始化了我们的人脸识别器,也准备了我们的训练数据,现在是时候训练人脸识别器了。我们将通过调用人脸识别器的序列(面向量,标签向量)方法来实现这一点。

    #训练我们的面部识别器
    face_recognizer.train(faces, np.array(labels))

      你有没有注意到,不是直接将标签矢量直接传递给人脸识别器,而是先把它转换成numpy数组?这是因为OpenCV希望标签向量是一个numpy数组。

      仍然不满意? 想看到一些行动? 下一步是真正的行动,我保证!

    预测

      现在是我最喜欢的部分,预测部分。 这是我们真正了解我们的算法是否确实能够识别受过训练的对象脸部的地方。 我们将拍摄两张我们的景点的测试图像,从他们每个人身上检测脸部,然后将这些脸部传递给我们训练有素的脸部识别器,看看它们是否识别它们。

      下面是一些实用功能,我们将用它来绘制围绕脸部的边界框(矩形)并将边界名称放在边界框附近。

    #函数在图像上绘制矩形
    #根据给定的(x,y)坐标和
    #给定的宽度和高度
    def draw_rectangle(img, rect):
        (x, y, w, h) = rect
        cv2.rectangle(img, (x, y), (x+w, y+h), (0, 255, 0), 2)
        
    #函数在从图像开始绘制文本
    #通过(x,y)坐标。
    def draw_text(img, text, x, y):
        cv2.putText(img, text, (x, y), cv2.FONT_HERSHEY_PLAIN, 1.5, (0, 255, 0), 2)

      第一个函数draw_rectangle根据传入的矩形坐标在图像上绘制一个矩形。 它使用OpenCV的内置函数cv2.rectangle(img,topLeftPoint,bottomRightPoint,rgbColor,lineWidth)绘制矩形。 我们将使用它在测试图像中检测到的脸部周围画一个矩形。

      第二个函数draw_text使用OpenCV的内置函数cv2.putText(img,text,startPoint,font,fontSize,rgbColor,lineWidth)在图像上绘制文本。

      既然我们有绘图功能,我们只需要调用人脸识别器预测(人脸)方法来测试我们的测试图像上的人脸识别器。 以下功能为我们做了预测。

    #this function recognizes the person in image passed
    #and draws a rectangle around detected face with name of the 
    #学科
    def predict(test_img):
        #制作图像的副本,因为我们不想更改原始图像
        img = test_img.copy()
        #从图像中检测脸部
        face, rect = detect_face(img)
    
        #使用我们的脸部识别器预测图像
        label= face_recognizer.predict(face)
        #获取由人脸识别器返回的相应标签的名称
        label_text = subjects[label]
        
        #在检测到的脸部周围画一个矩形
        draw_rectangle(img, rect)
        #画预计人的名字
        draw_text(img, label_text, rect[0], rect[1]-5)
        
        return img

    第6行读取测试图像
    第7行从测试图像中检测脸部
    第11行通过调用面部识别器的预测(面部)方法来识别面部。 该方法将返回一个标签
    第12行获取与标签关联的名称
    第16行在检测到的脸部周围绘制矩形
    第18行绘制预测主体在面部矩形上方的名称

      现在我们已经很好地定义了预测函数,下一步就是在我们的测试图像上实际调用这个函数,并显示这些测试图像以查看我们的人脸识别器是否能正确识别它们。 所以让我们来做。 这就是我们一直在等待的。

    print("Predicting images...")
    
    #加载测试图像
    test_img1 = cv2.imread("test-data/test1.jpg")
    test_img2 = cv2.imread("test-data/test2.jpg")
    
    #执行预测
    predicted_img1 = predict(test_img1)
    predicted_img2 = predict(test_img2)
    print("Prediction complete")
    
    #显示两个图像
    cv2.imshow(subjects[1], predicted_img1)
    cv2.imshow(subjects[2], predicted_img2)
    cv2.waitKey(0)
    cv2.destroyAllWindows()
    Predicting images...
    Prediction complete

    结语

      你可以从这个Github下载完整的代码和相关文件 打开GitHab.

      人脸识别是一个非常有趣的想法,OpenCV使得它非常简单,易于我们对其进行编码。 只需几行代码即可完成全面工作的人脸识别应用程序,并且我们可以通过一行代码更改在所有三个人脸识别器之间切换。 就这么简单。

      尽管EigenFaces,FisherFaces和LBPH人脸识别器都不错,但是使用面向梯度直方图(HOG)和神经网络进行人脸识别还有更好的方法。 因此,更先进的人脸识别算法现在是一个使用OpenCV和机器学习相结合的日子。 我还计划写一些关于这些更高级方法的文章,敬请关注!

    
    
    
  • 相关阅读:
    关于Windows程序设计的初步认识
    C++虚函数作用原理(二)——类的继承
    史诗级Java资源大全中文版
    马上加薪!测试,你的职业发展...
    你不知道的接口自动化测试!
    69道Spring面试题和答案,简单明了无套路
    大厂都在问的多线程面试题,你不了解下嘛?
    现代Java进阶之路必备技能——2019 版
    80后程序员降薪6K,预感中年危机来袭,准备跳槽却碰壁
    微服务、分布式、高并发都不懂,你拿什么去跳槽?
  • 原文地址:https://www.cnblogs.com/zhuifeng-mayi/p/9171383.html
Copyright © 2020-2023  润新知