深度学习实战(六):从零开始实现表情识别

  • 1. 项目简介
  • 2. 数据获取
    • 2.1 数据爬取
    • 2.2 数据整理
    • 2.3 数据清洗
    • 2.4 提取嘴唇区域
  • 3. 模型训练
    • 3.1 数据接口准备
      • 3.1.1 datasets.ImageFolder
      • 3.1.2 Transforms和DataLoader
    • 3.2 模型定义
      • 3.2.2 网络结构定义
      • 3.2.3 forward方法
    • 3.3 优化方法和优化目标
    • 3.4 添加可视化代码
    • 3.5 模型训练
  • 4. 模型测试

相关资源来自言有三AI(强推)丰富的AI学习资源

1. 项目简介

  为了让新手们能够一次性体验一个工业级别的图像分类任务的完整流程,本次我们选择带领大家完成一个对视频中人脸进行表情识别的任务。人脸表情识别(facial expression recognition, FER)作为人脸识别技术中的一个重要组成部分,近年来在人机交互、安全、机器人制造、自动化、医疗、通信和驾驶领域得到了广泛的关注,成为学术界和工业界的研究热点,是人脸属性分析的重点。
本项目有以下几个特点:

  1. 任务常见且比较基础。本次选择的是一个基于嘴唇来识别4种常见表情的任务,之所以选择嘴唇而不是整个人脸,一方面是因为嘴唇部分效果比较稳定显著,另外一方面是可以让大家使用OpenCV对人脸进行和关键点进行检测,加深数据预处理的理解。
  2. 流程非常完善。包括从数据的获取,数据的预处理,到模型的定义,模型训练,模型测试,麻雀虽小五脏俱全,对于初学者理解CV任务是非常合适的,这也算是我们平台的金标准任务了,在各个地方都介绍过。

  下面就是项目的视频效果展示,可以识别4种表情,包括无表情(neural),嘟嘴(pouting),微笑(smile),张嘴(open)。

2. 数据获取

  很多实际项目我们不会有现成的数据集,虽然可以通过开源数据集获取,但是我们还是要学会自己从零开始获取和整理。下面讲述如何准备好本次项目所需要的数据集,包括以下部分。

  1. 学会使用爬虫爬取图像。
  2. 对获得的图片数据进行整理,包括重命名,格式统一。
  3. 利用OpenCV的人脸检测算法删选出有用的样本,利用Dlib关键点检测算法裁剪出用于算法训练的嘴唇区域。

2.1 数据爬取

  由于没有直接对应的开源数据集,或者开源数据集中的数据比较少,尤其是对于嘟嘴,张嘴等类的数据,而搜索引擎上有海量数据,所以我们可以从中爬取。下面开始讲述具体的步骤,我们的任务是一个表情分类任务,因此需要爬取相关图片,包括嘟嘴,微笑,大笑等表情。
  这里为大家推荐一个爬虫工具。Google,Baidu,Bing三大搜素引擎图片爬虫这个爬虫由ID为sczhengyabin的用户整理。可以按要求爬取百度、Bing、Google上的图片,我已经用了几年了,提供了非常人性化的GUI方便操作,使用python image_downloader_gui.py调用GUI界面,配置好参数(关键词,路径,爬取数目等),关键词可以直接在这里输入也可以选择从txt文件中选择。可以配置需要爬取的样本数目,这里一次爬了2000张,妥妥的3分钟搞定。

  我们对百度搜索引擎,自行定义搜索词(比如嘟嘴),爬取图片结果如下:

2.2 数据整理

  爬取得到的数据是比较脏的,需要进行整理,主要包括统一图片后缀和重命名。统一后缀格式可以减少以后写数据API时的压力,也可以测试图片是不是可以正常的读取,及时防止未知问题的出现,这很重要。

  首先我们看下爬取完毕的数据有多少种数据格式。

  总共1364张图,可以看到有jpeg,bmp,png格式,我们首先将其全部转换为jpg格式,这也是所有框架支持的格式,格式转换代码如下:

import os
import sys
import cv2
import numpy as npdef listfiles(rootDir):list_dirs = os.walk(rootDir)for root, dirs, files in list_dirs:for d in dirs:print os.path.join(root,d)for f in files:fileid = f.split('.')[0] filepath = os.path.join(root,f)try:src = cv2.imread(filepath,1)print "src=",filepath,src.shapeos.remove(filepath)                          cv2.imwrite(os.path.join(root,fileid+".jpg"),src)except:os.remove(filepath)continue
listfiles(sys.argv[1]) ##输入文件夹即可

统一格式为jpg之后预览如下:

2.3 数据清洗

  利用搜索引擎爬取得到的图片肯定有不符合要求的,数据清洗主要是删除不合适的图片,即非人脸的照片。

  可以采用肉眼观察的方式,也可以利用程序进行筛选,我们调用opencv的人脸检测算法进行筛选,代码如下:

#coding:utf8
import cv2
import dlib
import numpy as np
import sys
import oscascade_path='haarcascade_frontalface_default.xml'
cascade = cv2.CascadeClassifier(cascade_path)
images = os.listdir(sys.argv[1])
for image in images:im=cv2.imread(os.path.join(sys.argv[1],image),1)rects = cascade.detectMultiScale(im, 1.3,5)print "detected face",len(rects)if len(rects) == 0:cv2.namedWindow('Result',0)cv2.imshow('Result',im)os.remove(os.path.join(sys.argv[1],image))k =cv2.waitKey(0)if k == ord('q'):break

  这个人脸检测算法是传统算法,召回率不高,因此会有一些好样本被删除。

  最后剩下732张样本,可以看到都是比较好的样本了,后面提取人脸关键点也会简单很多。

2.4 提取嘴唇区域

  接下来我们要将样本处理成我们真正训练所需要的图像,本任务只对嘴唇部分的表情进行识别,我们利用Opencv+Dlib算法提取嘴唇区域,Dlib算法会得到面部的68个关键点,我们从中得到嘴唇区域,并适当扩大。

import cv2
import dlib
import numpy as np
import sys
import osPREDICTOR_PATH = "shape_predictor_68_face_landmarks.dat"
predictor = dlib.shape_predictor(PREDICTOR_PATH)
cascade_path='haarcascade_frontalface_default.xml'
cascade = cv2.CascadeClassifier(cascade_path)def get_landmarks(im):rects = cascade.detectMultiScale(im, 1.3,5)x,y,w,h =rects[0]rect=dlib.rectangle(x,y,x+w,y+h)return np.matrix([[p.x, p.y] for p in predictor(im, rect).parts()])def annotate_landmarks(im, landmarks):im = im.copy()for idx, point in enumerate(landmarks):pos = (point[0, 0], point[0, 1])cv2.putText(im, str(idx), pos,fontFace=cv2.FONT_HERSHEY_SCRIPT_SIMPLEX,fontScale=0.4,color=(0, 0, 255))cv2.circle(im, pos, 5, color=(0, 255, 255))return imdef getlipfromimage(im,landmarks):xmin = 10000xmax = 0ymin = 10000ymax = 0for i in range(48,67):x = landmarks[i,0]y = landmarks[i,1]if x < xmin:xmin = xif x > xmax:xmax = xif y < ymin:ymin = yif y > ymax:ymax = yprint "xmin=",xminprint "xmax=",xmaxprint "ymin=",yminprint "ymax=",ymaxroiwidth = xmax - xminroiheight = ymax - yminroi = im[ymin:ymax,xmin:xmax,0:3]if roiwidth > roiheight:dstlen = 1.5*roiwidthelse:dstlen = 1.5*roiheightdiff_xlen = dstlen - roiwidthdiff_ylen = dstlen - roiheightnewx = xminnewy = yminimagerows,imagecols,channel = im.shapeif newx >= diff_xlen/2 and newx + roiwidth + diff_xlen/2 < imagecols:newx  = newx - diff_xlen/2;elif newx < diff_xlen/2:newx = 0;else:newx =  imagecols - dstlen;if newy >= diff_ylen/2 and newy + roiheight + diff_ylen/2 < imagerows:newy  = newy - diff_ylen/2;elif newy < diff_ylen/2:newy = 0;else:newy =  imagecols - dstlen;roi = im[int(newy):int(newy+dstlen),int(newx):int(newx+dstlen),0:3]return roidef listfiles(rootDir):list_dirs = os.walk(rootDir)for root, dirs, files in list_dirs:for d in dirs:print os.path.join(root,d)for f in files:fileid = f.split('.')[0]filepath = os.path.join(root,f)try:im = cv2.imread(filepath,1)landmarks = get_landmarks(im)roi = getlipfromimage(im,landmarks)roipath = filepath.replace('.jpg','_mouth.png')cv2.imwrite(roipath,roi)except:print "error"continue
listfiles(sys.argv[1])

  结果如下,自此就获得了一类数据集。

  经过整理后,我们的完整数据集包括4类:

  一共15000多张图,包含微笑,嘟嘴,大笑,无表情4类,按照9:1均匀划分为训练集与测试集,各个类别的数据数目如下,格式被统一为128*128大小,jpg类型图像。

  • 无表情0none:4763 训练集4287 测试集476
  • 嘟嘴1pouting:3154 训练集2839 测试集315
  • 微笑2smile:4841 训练集4357 测试集484
  • 张嘴3openmouth:2348 训练集2114 测试集234

3. 模型训练

  得到了数据之后,接下来咱们使用Pytorch这个框架来进行模型的训练,当然你也可以选择使用其他框架,文末我们会提供13个开源框架的训练代码。
  整个训练流程包括数据接口准备、模型定义、结果保存与分析。

3.1 数据接口准备

  Pytorch可以使用torchvision的数据集读取接口来进行图像分类任务的读取,使用torchvision的transform接口来进行数据预处理与数据增强,核心代码如下:

import torchvision
from torchvision import datasets, models, transformsdata_dir = './data' ##数据目录
## 创建数据预处理函数,训练预处理包括随机裁剪缩放、随机翻转、归一化,验证预处理包括中心裁剪,归一化
data_transforms = {'train': transforms.Compose([transforms.RandomSizedCrop(48),transforms.RandomHorizontalFlip(),transforms.ToTensor(),transforms.Normalize([0.5,0.5,0.5], [0.5,0.5,0.5])]),'val': transforms.Compose([transforms.Scale(64),transforms.CenterCrop(48),transforms.ToTensor(),transforms.Normalize([0.5,0.5,0.5], [0.5,0.5,0.5])]),}## 使用torchvision的dataset ImageFolder接口读取数据image_datasets = {x: datasets.ImageFolder(os.path.join(data_dir, x),data_transforms[x]) for x in ['train', 'val']}## 创建数据指针,设置batch大小,shuffle,多进程数量dataloaders = {x: torch.utils.data.DataLoader(image_datasets[x],batch_size=16,shuffle=True,num_workers=4) for x in ['train', 'val']}

  下面对上述代码进行简单讲解

3.1.1 datasets.ImageFolder

  Pytorch的torchvision模块中提供了一个dataset 包,它包含了一些基本的数据集如mnist、coco、imagenet和一个通用的数据加载器ImageFolder,只需要将不同类别的数据放置在不同的目录下,就可以实现加载,对于我们这个任务来说,目录结构如下:

data
├── train
│ ├── 0
│ ├── 1
│ ├── 2
│ └── 3
└── val
├── 0
├── 1
├── 2
└── 3

  imagefolder有3个成员变量。

  self.classes:用一个list保存类名,就是文件夹的名字。

  self.class_to_idx:类名对应的索引,可以理解为 0、1、2、3 等。

  self.imgs:保存(imgpath,class),是图片和类别的数组。

3.1.2 Transforms和DataLoader

  在transforms中定义了一系列数据集的预处理和增强操作,比如训练预处理包括随机裁剪缩放、随机翻转、归一化,验证预处理包括中心裁剪,归一化。然后使用torch.utils.data.DataLoader创建数据指针。

3.2 模型定义

  创建数据接口后,我们在网络脚本文件net.py中定义一个简单的模型simpleconv3。

## 3层卷积神经网络simpleconv3定义
## 包括3个卷积层,3个BN层,3个ReLU激活层,3个全连接层
class simpleconv3(nn.Module):## 初始化函数def __init__(self,nclass):super(simpleconv3,self).__init__()self.conv1 = nn.Conv2d(3, 12, 3, 2) #输入图片大小为3*48*48,输出特征图大小为12*23*23,卷积核大小为3*3,步长为2self.bn1 = nn.BatchNorm2d(12)self.conv2 = nn.Conv2d(12, 24, 3, 2) #输入图片大小为12*23*23,输出特征图大小为24*11*11,卷积核大小为3*3,步长为2self.bn2 = nn.BatchNorm2d(24)self.conv3 = nn.Conv2d(24, 48, 3, 2) #输入图片大小为24*11*11,输出特征图大小为48*5*5,卷积核大小为3*3,步长为2self.bn3 = nn.BatchNorm2d(48)self.fc1 = nn.Linear(48 * 5 * 5 , 1200) #输入向量长为48*5*5=1200,输出向量长为1200self.fc2 = nn.Linear(1200 , 128) #输入向量长为1200,输出向量长为128self.fc3 = nn.Linear(128 , nclass) #输入向量长为128,输出向量长为nclass,等于类别数## 前向函数def forward(self, x):## relu函数,不需要进行实例化,直接进行调用## conv,fc层需要调用nn.Module进行实例化x = F.relu(self.bn1(self.conv1(x)))x = F.relu(self.bn2(self.conv2(x)))x = F.relu(self.bn3(self.conv3(x)))x = x.view(-1 , 48 * 5 * 5) x = F.relu(self.fc1(x))x = F.relu(self.fc2(x))x = self.fc3(x)return x

  上面就是我们定义的网络,是一个简单的3层网络,包括3个卷积层,3个BN层,3个ReLU激活层,3个全连接层,要求输入的图像大小是34848,每一层特征图的大小可以使用print函数来查看。核心代码包括以下几个部分

  ### 3.2.1 simpleconv3(nn.Module)继承
继承nn.Module,Pytorch的网络层是包含在nn.Module 里,所以所有的网络定义,都需要继承该网络层,并实现super方法,如下:

super(simpleconv3,self).__init__()

3.2.2 网络结构定义

torch.nn.Conv2d(in_channels, out_channels, kernel_size, stride=1, padding=0, dilation=1, groups=1, bias=True)

  网络定义在nn包中,完整的接口如上,定义的第一个卷积层如下,输入通道为3,输出通道为12,卷积核大小为3,stride=2:

nn.Conv2d(3, 12, 3, 2)

3.2.3 forward方法

  backward方法不需要自己实现,但是forward函数是必须要自己实现的,从上面可以看出,forward 函数也是非常简单,串接各个网络层就可以了。Pytorch已经提供了默认初始化,如果我们想实现自己的初始化,可以这么做:

init.xavier_uniform(self.conv1.weight)init.constant(self.conv1.bias, 0.1)

  它会对conv1的权重和偏置进行初始化。如果要对所有conv层使用 xavier 初始化呢?可以定义一个函数:

def weights_init(m):    if isinstance(m, nn.Conv2d):xavier(m.weight.data)xavier(m.bias.data)  net = Net()  net.apply(weights_init)

3.3 优化方法和优化目标

  接下来看优化方法和优化目标的定义

criterion = nn.CrossEntropyLoss()
optimizer_ft = optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
exp_lr_scheduler = lr_scheduler.StepLR(optimizer_ft, step_size=100, gamma=0.1)

  可以看出优化目标使用了交叉熵,优化方法使用带动量项的SGD,学习率迭代策略为step,每隔100个epoch,变为原来的0.1倍。

3.4 添加可视化代码

  为了方便监控训练过程,我们可以使用TensorboardX进行可视化。Tensorboard的具体使用分三步。
  第一步,引入包定义创建变量

from tensorboardX import SummaryWriter
writer = SummaryWriter()

  第二步,记录变量,如train阶段的 loss

writer.add_scalar('data/trainloss', epoch_loss, epoch)

  第三步,在终端根据提示打开tensorboardX,比如打开日志目录logs下的文件

tensorboard --logdir=logs

  然后在浏览器中根据提示打开网页。

3.5 模型训练

  接下来看训练的核心代码train.py,如下。

## 训练主函数
def train_model(model, criterion, optimizer, scheduler, num_epochs=25):for epoch in range(num_epochs):print('Epoch {}/{}'.format(epoch, num_epochs - 1))for phase in ['train', 'val']:if phase == 'train':scheduler.step()model.train(True)  ## 设置为训练模式else:model.train(False)  ## 设置为验证模式running_loss = 0.0 ## 损失变量running_accs = 0.0 ##精度变量## 从dataloaders中获得数据for data in dataloaders[phase]:inputs, labels = data if use_gpu:inputs = inputs.cuda()labels = labels.cuda()optimizer.zero_grad() ##清空梯度outputs = model(inputs) ##前向运行_, preds = torch.max(outputs.data, 1) ##使用max()函数对输出值进行操作,得到预测值索引loss = criterion(outputs, labels) ##计算损失if phase == 'train':loss.backward() ##误差反向传播optimizer.step() ##参数更新running_loss += loss.data.item()running_accs += torch.sum(preds == labels).item()## 得到每一个epoch的平均损失与精度epoch_loss = running_loss / dataset_sizes[phase]epoch_acc = running_accs / dataset_sizes[phase]## 收集精度和损失用于可视化if phase == 'train':writer.add_scalar('data/trainloss', epoch_loss, epoch)writer.add_scalar('data/trainacc', epoch_acc, epoch)else:writer.add_scalar('data/valloss', epoch_loss, epoch)writer.add_scalar('data/valacc', epoch_acc, epoch)print('{} Loss: {:.4f} Acc: {:.4f}'.format(phase, epoch_loss, epoch_acc))writer.close()return model

  分析一下上面的代码,外层循环是epoches,然后利用 for data in dataloders[phase] 循环取一个epoch 的数据,送入model。

  需要注意的是,每一次forward要将梯度清零,即optimizer.zero_grad(),因为梯度会记录前一次的状态,然后计算loss进行反向传播。

loss.backward()
optimizer.step()

  下面可以分别得到精度acc和损失loss,每一次epoch完成计算。

epoch_loss = running_loss / dataset_sizes[phase]
epoch_acc = running_accs / dataset_sizes[phase]
_, preds = torch.max(outputs.data, 1)
loss = criterion(outputs, labels)

  在浏览器中进行实时监控,得到如下所示的训练结果图,从而可以判断模型的收敛情况。

4. 模型测试

  上面已经训练好了模型,我们接下来的目标,就是要用它来做推理,真正把模型用起来,下面我们载入一个图片,用模型进行测试。

#coding:utf8# Copyright 2019 longpeng2008. All Rights Reserved.# Licensed under the Apache License, Version 2.0 (the "License");# If you find any problem,please contact us##     longpeng2008to2012@gmail.com ## or create issues# =============================================================================import sys
import numpy as np
import cv2
import os
import dlib
import torch
import torch.nn as nn
import torch.optim as optim
from torch.optim import lr_scheduler
from torch.autograd import Variable
import torchvision
from torchvision import datasets, models, transforms
import time
import os
from PIL import Image
import sys
import torch.nn.functional as F## 全局变量
## sys.argv[1] 权重文件
## sys.argv[2] 图像文件夹
testsize = 48 ##测试图大小from net import simpleconv3net = simpleconv3(2) ## 定义模型net.eval() ## 设置推理模式,使得dropout和batchnorm等网络层在train和val模式间切换torch.no_grad() ## 停止autograd模块的工作,以起到加速和节省显存## 载入模型权重
modelpath = sys.argv[1]
net.load_state_dict(torch.load(modelpath,map_location=lambda storage,loc: storage))
## 定义预处理函数
data_transforms =  transforms.Compose([transforms.Resize(48),transforms.ToTensor(),transforms.Normalize([0.5,0.5,0.5], [0.5,0.5,0.5])])
## 读取3通道图片,并扩充为4通道tensor
imagepath = sys.argv[2]image = Image.open(imagepath)imgblob = data_transforms(image).unsqueeze(0)
## 获得预测结果predict,得到预测的标签值labelpredict = net(imgblob)index = np.argmax(predict.detach().numpy())## print(predict)## print(index)

  从上面的代码可知,需要做的事情包括:

  1. 定义网络并使用torch.load和load_state_dict载入模型。
  2. 使用net.eval()设置推理模式,使得dropout和batchnorm等网络层在train和val模式间切换,使用torch.no_grad()停止autograd模块的工作,以起到加速和节省显存。
    
  3. 使用PIL的Image读取图片,它会将图片按照RGB的格式,归一化到 0~1 之间。读取图片之后,必须转化为Tensor变量。

  然后就可以自己输入图片得到推理结果,index就是预测的类别。
项目源码:公主号AiCharm 输入“表情识别”

深度学习实战(六):从零开始实现表情识别相关推荐

  1. 【深度学习实战】从零开始深度学习(五):生成对抗网络——深度学习中的非监督学习问题

    参考资料: <PyTorch深度学习>(人民邮电出版社)第7章 生成网络 PyTorch官方文档 廖星宇著<深度学习入门之Pytorch>第6章 生成对抗网络 其他参考的网络资 ...

  2. 【深度学习】基于caffe的表情识别(二):数据集介绍及处理

    <基于caffe的表情识别>系列文章索引:http://blog.csdn.net/pangyunsheng/article/details/79434263 一.数据集介绍 在本实验中我 ...

  3. 深度学习实战之手写签名识别(100%准确率、语音播报)

    手写签名在日常生活中随处可见,简单来说就是亲笔书写自己的名字,在纸质文档上使用手写签名主要用以确定签字者的身份,并表示签字者同意所签署文档中规定的内容,对文档的真实性负责,且具有法律效力.由此看见手写 ...

  4. TF2.0深度学习实战(一):分类问题之手写数字识别

    前言: 本专栏将分享从零开始搭建神经网络的学习过程,力争打造最易上手的小白教程.在这过程中,我将使用谷歌TensorFlow2 框架逐一复现经典的卷积神经网络:LeNet-5.AlexNet.VGG系 ...

  5. Keras深度学习实战(37)——手写文字识别

    Keras深度学习实战(37)--手写文字识别 0. 前言 1. 手写文字识别相关背景 1.1 Connectionist temporal classification (CTC) 1.2 解码 C ...

  6. 深度学习实战14(进阶版)-手写文字OCR识别,手写笔记也可以识别了

    大家好,我是微学AI,今天给大家带来手写OCR识别的项目.手写的文稿在日常生活中较为常见,比如笔记.会议记录,合同签名.手写书信等,手写体的文字到处都有,所以针对手写体识别也是有较大的需求.目前手写体 ...

  7. 《Web安全之深度学习实战》笔记:第十三章 DGA域名识别

    本小节是讲解DGA域名的识别,在<web安全之机器学习入门>中,曾经通过多节来讲解DGA域名,相关笔记如下: <Web安全之机器学习入门>笔记:第七章 7.6朴素贝叶斯检测DG ...

  8. 【第 07 章 基于主成分分析的人脸二维码识别MATLAB深度学习实战案例】

    基于主成分分析的人脸二维码识别MATLAB深度学习实战案例 人脸库 全套文件资料目录下载链接–>传送门 本文全文源码下载[链接–>传送门] 如下分析: 主文件 function varar ...

  9. 深度学习实战39-U-Net模型在医学影像识别分割上的应用技巧,以细胞核分割任务为例

    大家好,我是微学AI,今天给大家介绍一下深度学习实战39-U-Net模型在医学影像识别分割上的应用技巧,以细胞核分割任务为例.本文将介绍在医学影像分割领域中应用U-Net模型的方法.我们将从U-Net ...

最新文章

  1. 三十一、具有快表的地址变换机构
  2. hdu oj1093题解
  3. SAP SD销售收入科目确定VKOA
  4. wxWidgets:wxModule类用法
  5. Linux Polkit 中的pkexec 组件存在的本地权限提升漏洞(CVE-2021-4034)修复方法及centos6和centos7的安装包
  6. Ubuntu中恢复rm命令误删文件
  7. Linux中文显示:解决Windows传到linux文件中文乱码
  8. 板邓:php+mayql分页原理及案例
  9. 一万年太久,只争朝夕_散文网
  10. Unity3D-----简易游戏项目开发01
  11. Js、 replace 全部内容替换、替换全部匹配内容、替换第一个
  12. 东北猫咪带我躺平末世
  13. 幅相曲线渐近线_幅相频率特性曲线.PPT
  14. android 个推封装,Android个推快速集成
  15. 亲测三大热门短链接平台,告诉你谁才是最好的
  16. netstat 的各个 state 什么意思
  17. C语言程序设计--商品信息管理系统
  18. 银河麒麟V10操作命令
  19. Hadoop Streaming的基本原理与参数解析
  20. 用SQL语句查询一个特定老师学生中男生女生各多少人

热门文章

  1. 超详细的抖音运营全攻略
  2. 员工管理系统(服务器和客户端)
  3. Map.containsKey() 的一个使用场景.
  4. 2022新版海螺影视主题模板M3.1全解密版本多功能苹果CMSv10后台自适应主题
  5. FFMPEG源码编译(Windows篇)
  6. 初步使用计算机教学设计,【教资笔试——科目三】信息技术教学设计范例
  7. vue调用高德地图实现定位
  8. ios 历年wwdc
  9. 最菜的我打卡的第二天
  10. E: Unable to locate package python-distutils The command ‘/bin/sh -c apt-get update apt-get insta