第5章 卷积神经网络 第5章卷积神经网络 卷积神经网络能够大大减少网络的参数量,在计算机视觉任务中表现优异。 5.1卷积 5.1.1矩阵的内积 4min 我们之前一直在使用矩阵的乘法,因为输入数据的值和它们对结果的影响程度并不是同一个东西,所以数据横着写,权重竖着写。 那么有没有等价的情况呢?有,两张图片之间可以进行内积运算,即相应元素相乘再相加,这个积被称为内积,可以衡量两张图片的相似程度,如图51所示。 图51内积 我们在这里获得了一种寻找图片上特征的方式——让采样器与其进行内积计算。通常采样器是3×3或5×5的,而图片可以很大,例如1080×1920,如果采样器在图像上每个位置采样一次,便可以知道图像上的那个位置是否有与采样器一致的像素,如图52所示。例如图片是一张猫或狗的图片,采样器是猫的胡子,显然在猫的图片上能在胡子的位置输出一个较大的值表示找到了,并提高模型认为图片是猫的概率,而狗图片上却找不到猫的胡子,各个位置输出的值都很小。 图52卷积 这种运算被称为卷积,它来自生物学的启发,当动物看不同物体的时候,大脑激活的区域不同,对应到神经网络中,就是有许多不同的采样器,负责寻找图像上不同形状的物体。 尽管3×3或5×5的采样器看起来太小,没什么用,但神经网络是可以叠加多层的,当多个卷积层叠加时,底层的卷积层抽取局部、简单的特征,例如直线、曲线、折线; 中层的卷积层抽取稍复杂的特征,例如人的脸、汽车的轮胎; 高层的卷积层抽取整体、复杂的特征,例如人、车和狗。 5.1.2卷积的代码实现 1. 向量内积 为了简明,我们不使用Tensor,只使用Python提供的列表。 #Chapter05/05-1/1.conv.py def dot(x: list, y: list) -> float: result = 0 for i in range(len(x)): result += x[i] * y[i] return result 测试代码如下: if __name__ == '__main__': x = [1, 2, 3, 4, 5] y = [2, 3, 4, 5, 6] print(dot(x, y)) 输出如下: 70 2. 向量卷积 卷积结果的某一维度的大小按以下公式计算 ConvResultSize=SrcSize-KernelSize+2*PaddingStride+1(51) 其中ConvResultSize为卷积结果的尺寸,SrcSize为输入尺寸,KernelSize为卷积核尺寸,Padding为填充数,Stride为步长。 设置两层循环,外层循环控制卷积核位置,内层循环计算卷积结果,不考虑填充数,代码如下: #Chapter05/05-1/1.conv.py def conv1d_without_padding (x, y, stride=1, padding=0): src_size = len(x) Kernel_size = len(y) result_size = int((src_size - Kernel_size + 2 * padding) / stride + 1) result = [] for i in range(result_size,stride): sum = 0 for j in range(Kernel_size): sum += x[i + j] * y[j] result.append(sum) return result 当需要填充时,并不需要真的在输入列表里加0,因为0乘任何数的结果都为0,所以需要修改卷积的范围,对于范围外的取值忽略,同时外层循环从-padding开始,结束位置为result_size-padding,保持结果为result_size个,代码如下: #Chapter05/05-1/1.conv.py def conv1d(x, y, stride=1, padding=0): src_size = len(x) Kernel_size = len(y) result_size = int((src_size - Kernel_size + 2 * padding) / stride + 1) result = [] for i in range(- padding, result_size - padding, stride): sum = 0 for j in range(Kernel_size): if i + j < 0 or i + j >= src_size: continue sum += x[i + j] * y[j] result.append(sum) return result 3. 矩阵卷积 需要设置4层循环,外面两层控制卷积核位置,里面两层进行卷积运算,代码如下: #Chapter05/05-1/1.conv.py def conv2d_primitive(x, y, stride=1, padding=0): src_height = len(x) src_width = len(x[0]) Kernel_height = len(y) Kernel_width = len(y[0]) result_height = int((src_height - Kernel_height + 2 * padding) / stride + 1) result_width = int((src_width - Kernel_width + 2 * padding) / stride + 1) result = [] for i in range(-padding, result_height - padding, stride): line = [] for j in range(-padding, result_width - padding, stride): sum = 0 for k in range(Kernel_height): for l in range(Kernel_width): if i + k < 0 or i + k > src_height or j + l < 0 or j + l > src_height: return sum += x[i + k][j + l] * y[k][l] line.append(sum) result.append(line) return result 4. 二维卷积 也就是torch.nn.Conv2d,实际上这个2d指的是卷积核的移动是二维的,但输入和输出的数据、卷积核通常都是三维的,且输入数据的第3个维度与卷积核的第3个维度长度必须相同。其控制遍历和运算的4层循环与矩阵卷积一致,在原图像上从左到右,从上到下进行遍历,区别是图片的第3个维度,这个维度不参与位置控制,只是单纯地对对应位置累加。例如,对黑白图片,其形状为[28,28,1],则在[0,0]位置卷积时,其第3个维度所对应的那个数相加,此情况与矩阵卷积类似。对彩色图片,其形状为[224,224,3],则在[0,0]位置卷积时,其第3个维度有3个数,分别为红、绿、蓝,与卷积核第3个维度的3个数对应相乘得到3个数,这3个数再相加得到结果[0,0]上的数。 我们以形状[3,3,3]的输入为例,要进行二维卷积,卷积核必须为[x,x,3],代码如下: x = torch.randn(3,3,3) Kernel = torch.randn(3,3,3) result[0][0] = (x*Kernel).sum() 也就是说不论x和Kernel的形状如何,卷积结果都是一个平面,但当卷积核有多个时,我们就会将这个平面在第3个维度堆起来, 变成一个立方体,代码如下: x = torch.randn(3,3,3) Kernel_1 = torch.randn(3,3,3) Kernel_2 = torch.randn(3,3,3) result[0][0][0] = (x*Kernel_1).sum() result[0][0][1] = (x*Kernel_2).sum() 不过,维度只是数据的组织方式,在第3个维度叠卷积结果并不是绝对的(但是是较直观的),例如PyTorch就不是如此,它将通道放在第1个维度,图片组织为形如[3,224,224],将卷积结果组织为形如[16,224,244],为了将通常的图片转换为PyTorch要求的图片,可以使用torchvision.transforms.ToTensor(),这是一个可调用对象,代码如下: image = np.random.randn(224,224,3) to_tensor = torchvision.transforms.ToTensor() image_PyTorch = to_tensor(image) image_PyTorch.shape 输出如下: torch.Size([3, 224, 224]) 其示意源码如下: class ToTensor: def __call__(self, *args, **kwargs): image = args[0] image = image / 255 image = image.transpose(2, 0, 1) return image 5.2卷积神经网络介绍 卷积神经网络的基本结构与普通的神经网络并无区别,都是千层饼,但在经典卷积神经网络中往往最后一层输出层才是全连接层,其他每层都是卷积+池化的组合。 5.2.1卷积层 卷积层顾名思义就是对输入进行卷积运算的层,在PyTorch中卷积层Conv2d在实例化为可调用对象时,需要传入的参数为输入通道、输出通道(采样器的个数)和采样器的大小,以及采样器移动的步长,代码如下: import torch conv_layer1 = torch.nn.Conv2d(in_channels=1,out_channels=16,Kernel_size=3,stride=1,padding=1) dummy_image = torch.randn(1,1,28,28) conv_layer1(dummy_image) 输入通道in_channels是指输入张量第3个轴,例如一张1080P彩色图片转换为张量后为[1080,1920,3]则其为3通道(每个像素都有红、黄、蓝3个值),黑白图片则只有1通道。 输出通道out_channels与采样器的数量相同,因为每个采样器都会对图片的每个位置进行卷积,生成一份卷积的结果,如图53所示。 图53卷积的结果 卷积核大小Kernel_size常用的有3、5和7。 步长stride是指卷积核每次移动的格数,通常为1,即逐位置卷积。 填充padding是在原图片周围补0的圈数,否则卷积过后图片的大小会发生变化。填充数按照公式(41)变形的[(W-1)*S+F-W]/2得到,W是图片的宽或高,S是步长,F是卷积核大小。当步长为1时,当卷积核大小为3且步长为1时,填充为1; 当卷积核大小为5,步长为1时,填充为2……以此类推,卷积核为2n+1时,填充为n。 注意: PyTorch规定输入卷积层的张量至少为4维: [batch_size,channel,image_height,image_width],第一个维度为一个批次中图片的数量,哪怕一个批次只有一张图片也要有这个维度。 5.2.2池化层 池化层是Pooling Layer的直译,它的另一个名字“下采样层”更易理解。它是为了减小运算量而设计的一种层。最常用的池化层为最大池化层,图像经过卷积层后获得卷积结果,每4个卷积结果取一个最大值,代表那一小块的信息,如图54所示。 图54池化层 平均池化也会被使用,即取4个值的平均值代表那一小块的卷积结果。 提示: 此种池化层中没有可训练的参数。 在PyTorch中池化层同样是一个继承自torch.nn.Module的可调用对象,代码如下: import torch maxpooling_layer1 = torch.nn.MaxPool2d(Kernel_size=2,stride=2) dummy_image = torch.randn(1,28,28) output = maxpooling_layer1(dummy_image) 注意: PyTorch的池化层是有偏置的。 5.2.3在PyTorch中构建卷积神经网络 卷积神经网络与前面介绍的全连接神经网络相比除了将全连接层换成卷积层之外,对输入数据的维度要求也不同。全连接层是简单的矩阵运算,因此要求数据形状为[batch_size,input_size]。2D卷积层要求图片是PyTorch图片格式,即[batch_size,channel,image_height,image_width]。 注意: 卷积运算本身对图片的大小没有要求,但卷积神经网络最后是一个全连接层(输出与标签值形状一致的结果),因此整个网络对图片的形状是有要求的。 因为通常卷积和池化是同时出现的,卷积之后紧接着就是池化,因此,我们使用torch.nn. Sequential将卷积、池化、激活函数装起来作为一个层(可调用对象),当调用Sequential时,它里面的这3个层都会被依次执行, Sequential示意源码如下: #Chapter05/05-1/2.Sequential.py import torch class Sequential(torch.nn.Module): def __init__(self, *args): super().__init__() self.modules = [] for module in args: self.modules.append(module) def forward(self, x): for module in self.modules: x = module(x) return x if __name__ == '__main__': x_data = torch.randn(3, 2) model = Sequential(torch.nn.Linear(2, 5), torch.nn.Linear(5, 10)) output = model(x_data) print(output) 因此使用Sequential定义卷积层的代码如下: conv_layer = torch.nn.Sequential( torch.nn.Conv2d(1, 16, Kernel_size=3, stride=1, padding=1), torch.nn.ReLU(), torch.nn.MaxPool2d(Kernel_size=2, stride=2)) dummy_image = torch.randn(1,1,28,28) output = conv_layer1 (dummy_image) 使用卷积神经网络进行MNIST手写数字数据集分类任务,代码如下: #Chapter05/05-1/3.CNN.py import torch import torchvision #设置超参数 num_epochs = 5 batch_size = 100 num_classes = 10 learning_rate = 0.001 #从TorchVision下载MNIST数据集 train_dataset = torchvision.datasets.MNIST(root='./data', train=True, transform=torchvision.transforms.ToTensor(), download=True) test_dataset = torchvision.datasets.MNIST(root='./data', train=False, transform=torchvision.transforms.ToTensor()) #使用PyTorch提供的DataLoader,以分批乱序的形式加载数据 train_loader = torch.utils.data.DataLoader(dataset=train_dataset, batch_size=batch_size, shuffle=True) test_loader = torch.utils.data.DataLoader(dataset=test_dataset, batch_size=batch_size, shuffle=False) #构建卷积神经网络 class ConvolutionalNeuralNetwork(torch.nn.Module): def __init__(self, num_classes=10): super(ConvolutionalNeuralNetwork, self).__init__() self.conv_layer1 = torch.nn.Sequential( torch.nn.Conv2d(1, 16, Kernel_size=3, stride=1, padding=1), torch.nn.ReLU(), torch.nn.MaxPool2d(Kernel_size=2, stride=2)) self.conv_layer2 = torch.nn.Sequential( torch.nn.Conv2d(16, 32, Kernel_size=3, stride=1, padding=1), torch.nn.ReLU(), torch.nn.MaxPool2d(Kernel_size=2, stride=2)) self.fc = torch.nn.Linear(7 * 7 * 32, num_classes) def forward(self, x): x = self.conv_layer1(x) x = self.conv_layer2(x) #将卷积层的结果拉成向量再通过全连接层 x = x.reshape(x.size(0), -1) x = self.fc(x) return x #实例化模型(可调用对象) model = ConvolutionalNeuralNetwork(num_classes) #设置损失函数和优化器 criterion = torch.nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate) #训练模型 total_step = len(train_loader) for epoch in range(num_epochs): for images, labels in train_loader: #Forward pass outputs = model(images) loss = criterion(outputs, labels) #Backward and optimize optimizer.zero_grad() loss.backward() optimizer.step() #检验模型在测试集上的准确性 correct = 0 total = 0 for images, labels in test_loader: outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print('Accuracy on test set: {} %'.format(100 * correct / total)) 5.2.4迁移学习 12min 3min 迁移学习即在大规模数据集上对训练得到的预训练模型进行微调,以便快速获得能够完成自定义任务的网络的方法。 PyTorchHub和TorchVision都提供预训练模型,后者包含我们常用的计算机视觉模型。 通过torchvision.models.resnet18()函数可以获得一个resnet18模型,若含有参数pretrained=True,则下载预训练参数(否则只有网络结构,其中的参数是随机初始化的),代码如下: model = torchvision.models.resnet18(pretrained=True) dummy_trainset = torch.randn(100,3,224,224) outputs = model(dummy_trainset) print(outputs) 使用这些预训练模型很简单,它们都是继承自torch.nn.Module的可调用对象,可以根据需要替换其中的层,也可以将整个模型当作一个层训练自己的模型,例如替换resnet18的最后一层使输出维度为n,以完成n分类任务,代码如下: model = torchvision.models.resnet18(pretrained=True) model.fc = torch.nn.Linear(512,num_classes) outputs = model(x_data) 注意: print(model)可以打印网络结构,因此可以看到resnet18的最后一层为(fc): Linear(in_features=512, out_features=1000, bias=True)。 如果我们搜集的数据只有几百张或几千张图片,这不足以训练大型模型,但是可以直接下载已经在大型数据集上训练完成的模型并使用自己搜集的数据集进行微调,其许多低层采样器已经相对合理,所以能更快收敛并完成任务。 5.2.5梯度消失 既然神经网络的每一层都能抽取上一层的特征,那么网络能无限制地叠加层数吗?很遗憾,不能,越深层的网络越难以训练。 我们以卷积层为例,使用卷积核大小为3,步长为1,填充为1的卷积,其不改变图像的大小,可以直接使用for循环进行叠加。同时考虑到这里的计算较复杂,我们使用.to(device)的方式将数据和模型放到GPU上运算(详见5.4.4节),代码如下: #Chapter05/05-1/5.resnet.py import torch import torchvision #设置超参数 batch_size = 100 input_size = 784 hidden_size = 1000 num_classes = 10 num_epochs = 5 learning_rate = 0.001 device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu') … conv_layer_number = 18 class NeuralNetwork(torch.nn.Module): def __init__(self): super(NeuralNetwork, self).__init__() self.conv_start = torch.nn.Sequential( torch.nn.Conv2d(1, 16, 3, 1, 1), torch.nn.ReLU() ) #卷积核尺寸为3,步长为1,填充为1,但不改变图片尺寸,可直接叠加 self.conv_loop = torch.nn.Sequential( torch.nn.Conv2d(16, 16, 3, 1, 1), torch.nn.ReLU() ) self.conv_end = torch.nn.Sequential( torch.nn.Conv2d(16, 1, 3, 1, 1), torch.nn.ReLU() ) self.fc = torch.nn.Linear(28 * 28, 10) def forward(self, x): x = self.conv_start(x) for i in range(conv_layer_number): x = self.conv_loop(x) x = self.conv_end(x) x = self.fc(x.reshape(-1, 28 * 28)) return x model = NeuralNetwork().to(device) #设置损失函数和优化器 criterion = torch.nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate) #训练模型 for epoch in range(num_epochs): for i, (images, labels) in enumerate(train_loader): images = images.to(device) labels = labels.to(device) outputs = model(images) loss = criterion(outputs, labels) #反向传播,算出Loss对各参数的梯度 optimizer.zero_grad() loss.backward() #更新参数 optimizer.step() #检验模型在测试集上的准确性 correct = 0 total = 0 for images, labels in test_loader: images = images.to(device) labels = labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print('Accuracy on test_set: {} %'.format(100 * correct / total)) 当conv_layer_number=10时,输出如下: Accuracy on test_set: 96.41 % 这没有什么问题,将conv_layer_number改成18,输出如下: Accuracy on test_set: 11.35 % 因为MNIST数据集是10分类问题(10个数字),所以瞎猜的正确率是10%,这与此时模型的正确率没有太大差别。为什么会这样呢?我们打印出训练过程的Loss,代码如下: #Chapter05/05-1/5.resnet.py for epoch in range(num_epochs): for i,(images, labels) in enumerate(train_loader): images = images.cuda() labels = labels.cuda() outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() if (i + 1) % 100 == 0: print(loss.item()) 输出如下: 2.300546169281006 2.298768997192383 2.3063318729400635 2.296616792678833 2.3011674880981445 … 2.2954554557800293 2.305342674255371 2.3037164211273193 Loss始终都没有太大变化,也就是说网络什么都没有学到,这是为什么呢? 我们换成打印网络中参数的梯度,代码如下: for param in model.conv_start.parameters(): print(param.grad) 输出如下: tensor([[[[ 0.0000e+00,0.0000e+00,0.0000e+00], [ 0.0000e+00,0.0000e+00,0.0000e+00], [ 0.0000e+00,0.0000e+00,0.0000e+00]]], … [[[-2.0122e-11, -2.0975e-11, -1.9259e-11], [-1.7811e-11, -1.7516e-11, -2.1044e-11], [-1.0119e-11, -1.2688e-11, -1.4489e-11]]], 网络中参数的梯度都非常小,小于10-10,直至小于计算机所能表示的最小浮点数而变成了0,这种现象被称为梯度消失。 在4.1.3节介绍反向传播时我们曾实际手动计算过导数,其中W1.grad=W5·W4·W3·W2·x1,即传播中会有关于W的激活函数求导结果连乘,当网络的层数增多时,这个连乘链会变得越来越长,类似指数地缩小,因为W是按标准正态分布初始化的,往往都是在(-1,1)区间的,这个连乘的结果很快就会接近于0。这还是对于激活函数ReLU而言的,若是Sigmoid函数,就更容易发生梯度消失了。反之,如果W初始化过大,就可能会出现梯度爆炸,即梯度快速增长直至超过计算机能表示的最大浮点数。 但是显然神经网络并不是只能叠10层以内,不过这就需要使用更多的技巧,而不是一train超人。例如在5.2.4节介绍的resnet18,就是18层带权重层(包括卷积层和全连接层,但不包括池化层和BatchNormal这些不含可训练参数的层),而通过torchvision.models还能看到resnet50、resnet152,因为残差网络的基本单元是一种名为残差块的层,层中提供捷径,PyTorch中源码如下(注意输入被备份到一个名为identity的变量上): #Python37\Lib\site-packages\torchvision\models\resnet.py def forward(self, x): identity = x out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) out += identity out = self.relu(out) return out 你可能会说,这不就是把输入复制了一份,然后加在经过了各个卷积层的最终结果上了吗?确实如此,但正是因为这条捷径的存在,在反向传播时梯度可以通过捷径传递,以此可以缓解梯度消失的问题。 因此按照这个思路修改模型: def forward(self, x): x = self.conv_start(x) for i in range(conv_layer_number): temp = x x = self.conv_loop(x) x += temp 输出如下: Accuracy on test_set: 97.63 % 简单地加上了这条捷径之后不仅解决了梯度消失的问题还成功训练了18层的卷积层,而且因为卷积层层数多、模型更深,在同样训练次数的情况下让预测的准确度进一步增加了。 5.3目标检测 7min 4min 普通的CNN可以检测照片中的物体种类甚至输出其位置,但我们很多时候要求模型能够识别图片中所有物体并标出其位置,例如在自动驾驶任务中,需要模型实时地检测视野中的行人和车辆。 5.3.1YOLO 8min 要定位图片主要物体的位置并不复杂,只要有对应的数据集,并在输出上添加拟合标签需要的维度即可。 例如要做一个行人车辆检测器,那么输出为[v,c1,c2,x,y,h,w],v(valid)表示图中是否有物体,若该位的值为0,则后面的维度全都无效。c1和c2为OneHot表示的图中物体种类,x、y、h和w为模型标出的物体方框的位置及高和宽,如图55所示。 图55目标定位 但是图中有多个物体,如果要全部输出有两种思路,一种思路是设置一个方框像卷积一样扫过整张图片,将每一次方框中的内容送入目标定位网络进行分类和定位; 另一种思路是将图片切分为许多块,每一块都产生一个输出,表明自己那块区域里是否有物体(判定依据为物体方框的几何中点在区域内), 图56YOLOv1 有物体及是什么种类并用一个方框标出它,如图56所示。 这便是YOLOv1算法。值得一提的是因为每个框都会汇报一个结果,所以我们采取了这样的策略: ①物体的坐标采用全局坐标,图片左上角的坐标是(0,0),右下角的坐标是(1,1),不会超过1,而物体的高和宽使用区域坐标,如果物体比一块区域更大,则会出现高和宽大于1的情况(如图52)。②多个格子可能汇报同一个物体,我们取其v值(置信度)最高的那个结果,并删去其他与其重叠范围大的汇报结果,该过程称为非极大值抑制。 该算法速度很快,但可以想象,若遇到密集的小物体,则效果会很差。 5.3.2FasterRCNN 在YOLOv1中我们手动地将图片划分为固定的7×7块区域,然后在每个区域上识别物体,而FasterRCNN的原理分两步走: 第一步: 找到物体所在的区域(自动划区),这部分网络被称为RPN网络; 第二步: 在划出的每个区域上识别物体。 显然,这相比YOLOv1来说更慢且训练更困难,但是效果更好,因为我们将更多的工作交给模型自己学习。 图片中可能含有物体的区域被称为候选区域(ROI),获得它也并不是一步完成的,首先我们需要在图片中构造出更可能多、各种形状的区域,这些预定义的框被称为Anchor,它们将图片切得支离破碎(且彼此重叠),如图57所示。 图57Anchors PyTorch官方实现里创建了5种尺寸、3种缩放系数的Anchor,代码如下: anchor_sizes = ((32,), (64,), (128,), (256,), (512,)) aspect_ratios = ((0.5, 1.0, 2.0),) * len(anchor_sizes) rpn_anchor_generator = AnchorGenerator( anchor_sizes, aspect_ratios ) 每移动一步,例如从左上角开始,一步移动16个像素,就可以创建一组Anchor。之后在这些Anchor上运行物体以便定位网络,筛选出有物体的区域(proposals)并进行初步定位,代码如下: detections, detector_losses = self.roi_heads(features, proposals, images.image_sizes, targets) 注意: 这里的Anchor数量非常多,所以这里运行的物体定位网络比后面精确定位的物体定位网络更简单。 之后传到网络的后半部分进行精确定位,即可得到识别结果,代码如下: detections = self.transform.postprocess(detections, images.image_sizes, original_image_sizes) 5.3.3在PyTorch中使用FasterRCNN 在PyTorch中使用FasterRCNN非常简单,只需使用torchvision.models.detection.fasterrcnn_resnet50_fpn()便可以获得一个FasterRCNN的可调用对象,代码如下: fastrcnn = torchvision.models.detection.fasterrcnn_resnet50_fpn() 值得注意的是,使用迁移学习微调该模型并不是按以前的方法自行梯度下降,获得的可调用对象如果只传入数据images,则只进行预测,如果传入数据images和标签targets,则进行训练,源码如下: def forward(self, images, targets=None): 训练模型,代码如下: #Chapter05/05-2/fastrcnn.py #获得模型 model = torchvision.models.detection.fasterrcnn_resnet50_fpn(pretrained=True) #训练模型 images, boxes = torch.rand(4, 3, 600, 1200), torch.rand(4, 11, 4) labels = torch.randint(1, 91, (4, 11)) images = list(image for image in images) targets = [] for i in range(len(images)): d = {} d['boxes'] = boxes[i] d['labels'] = labels[i] targets.append(d) output = model(images, targets) 使用该模型进行预测,代码如下: model.eval() x = [torch.rand(3, 300, 400), torch.rand(3, 500, 400)] predictions = model(x) 5.4实用工具 我们之前使用的都是PyTorch提供的已训练好的数据集,在理想环境中着重学习模型的搭建,然而在实践中并不总是有提供好的能够直接使用的Dataset对象,因此本节介绍一些处理数据的手段。 5.4.1图像处理 图片并不是按像素直接存储在磁盘上,而是会经过编码,例如bmp格式是不压缩,png格式是无损压缩,jpg格式是有损压缩,对同一张1080P图片,其3种格式的图片大小如图58所示。 图58图片格式 因此磁盘上存储的并不是图片的像素,我们也不能直接通过对标准文件读写的方式获得图片文件中需要的数据,如果不想查阅各种图片编码的规则来编写读取程序,就需要借助库。 1. Matplotlib Matplotlib提供基础的图片读取和显示的API,代码如下: #Chapter05/05-4/1.matplotlib.py import matplotlib.pyplot as plt //读取图片 image = plt.imread("./img1.jpg") //打印像素值 print(image) //保存一个ndarray数组为图片 plt.imsave("./img1_copy.jpg", image) //显示图片 plt.imshow(image) plt.show() 2. PIL TorchVision的各种图片操作均基于PIL库(Python Image Library),其部分API也仅接收PIL类型的数据而不接受通用的ndarray类型(因为特定数据类型提供特定的操作),其基本使用代码如下: import PIL.Image as Image image = Image.open("img1.jpg") image.show() 3. TorchVision 通常我们并不直接使用读取的图片,首先其大小就不一定都一样,而神经网络中的全连接层对输入维度是严格要求的。要对图片进行常规修改,可以使用torchvision.transforms提供的一些可调用对象,最常用的有①ToTensor: 将图片数据重排为PyTorch要求的格式和类型; ②RandomResizedCrop: 将图片裁剪为指定尺寸; ③RandomHorizontalFlip: 对图片随机进行水平翻转; ④Normalize: 对图片进行指定均值和方差的标准化。 1) ToTensor 将图片的像素/255变成[0,1]并转置为[channel,height,width],具体实现在5.1.2节提到过,使用代码如下: to_tensor = transforms.ToTensor() image_tensor = to_tensor(image) print(image) 注意,这时候的数据实际上已经不是图片了,虽然数据还是那些数据,但其已经重排为PyTorch要求的格式,且数据类型为Tesnor,而RandomResizedCrop、RandomHorizontalFlip两个API只接收PIL格式的图片。 2) RandomResizedCrop 对图片进行裁剪,实例化时传入要求的图片尺寸,代码如下: random_resized_crop = transforms.RandomResizedCrop(224) image_resized = random_resized_crop(image) image_resized.show() 3) RandomHorizontalFlip 按概率(默认50%)对图片进行水平翻转,这是一种数据增强的手段,实例化时可指定翻转的概率,代码如下: random_horizontal_flip = transforms.RandomHorizontalFlip(1) image_flipped = random_horizontal_flip(image) image_flipped.show() 4) Normalize 在4.3.2 节提到过,对模型进行标准化能显著提高模型的收敛速度,同时可用与ToTensor()作用相反的可调用对象ToPILImage将标准化后的数据转换成图片以便显示,代码如下: normalize = transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) image_normalize_tensor = normalize(image_tensor) image_normalize = transforms.ToPILImage()(image_normalize_tensor) image_normalize.show() 4. OpenCV OpenCV是计算机视觉领域最知名的开源计算机视觉库之一,如果需要进行一些复杂的图像处理,则可以考虑查阅它的官方文档看是否有相应的支持。其提供Python的API,可通过pip install opencvpython安装,使用import cv2导入,导入之后可以使用cv2.imread读取一张图片,返回一个包含像素的Numpy数据结构ndarray,代码如下: import cv2 image = cv2.imread("test.jpg") print(image) 输出如下: array([[[206, 206, 206], [206, 206, 206], [206, 206, 206], ..., [247, 247, 247], [247, 247, 247], [247, 247, 247]]], dtype=uint8) 需要注意的是,OpenCV使用的颜色通道为BGR而不是RGB,这其实与吃煮鸡蛋时先敲小头还是大头的道理相同,颜色通道的选择有其历史原因,与别的库交互时需使用cvtColor。 5. 原则 不要手动循环遍历像素修改值。因为纯Python速度很慢,不加处理会比C++慢100倍以上,而PyTorch、Numpy底层代码是C/C++,使用Python调用C/C++代码,既能保证开发效率,又能保证运行速度。 我们使用例子来说明这一点。PyTorch要求的图片的维度与图片存储的格式不一样,我们可以使用Python代码完成数据的重排,代码如下: #Chapter05/05-4/3.speed test.py def convert_ndarray_to_tensor_pure_python(image: np.ndarray): result = torch.zeros(image.shape[2], image.shape[0], image.shape[1]) for i in range(image.shape[0]): for j in range(image.shape[1]): for k in range(image.shape[2]): result[k][i][j] = image[i][j][k] / 255 return result 为了得到运行时间,需要导入time库,这是一个官方库,使用time.time可以获得当前时间的时间戳(1970年后任意选定时间经过的浮点秒数),通过代码运行前后获得的时间相减就可以得到程序段的运行时间,代码如下: #Chapter05/05-4/3.speed test.py import time start = time.time() image_tensor1 = torchvision.transforms.ToTensor()(src_image) end = time.time() print("torchvision.transforms.ToTensor cost:{} s", end - start) 使用ToTensor()能完成同样的功能,代码不再赘述。 将两者速度进行对比,代码如下: #Chapter05/05-4/3.speed test.py import torchvision import Numpy as np import matplotlib.pyplot as plt import torch import time def convert_ndarray_to_tensor_pure_python(image: np.ndarray): result = torch.zeros(image.shape[2], image.shape[0], image.shape[1]) for i in range(image.shape[0]): for j in range(image.shape[1]): for k in range(image.shape[2]): result[k][i][j] = image[i][j][k] / 255 return result src_image = plt.imread("./img1.jpg") start = time.time() image_tensor1 = torchvision.transforms.ToTensor()(src_image) end = time.time() print("torchvision.transforms.ToTensor cost:{} s", end - start) start = time.time() image_tensor2 = convert_ndarray_to_tensor_pure_python(src_image) end = time.time() print("convert_ndarray_to_tensor_pure_python cost:{} s", end - start) 输出如下: torchvision.transforms.ToTensor cost:0.001969575881958008 s convert_ndarray_to_tensor_pure_python cost:8.19406008720398 s 在这次测试中,对一张295×695像素的图片而言,使用纯Python比调用库函数慢了4000倍左右。实际上,一张小图片使用纯Python进行遍历耗时已经达到了秒级,这让其几乎没有实用性。 5.4.2保存与加载模型 在PyTorch中保存模型有两种形式: 保存state_dict(所有可训练的参数,不包括网络结构)或保存整个网络,前者是建议的形式,后者实际上是使用pickle将对象序列化并保存到磁盘上,当路径变动时会产生错误。 1. 保存模型 保存4.2.3节手写数字识别的模型,在文件的最后加上一句,代码如下: torch.save(model.state_dict(), './model.ckpt') 这样训练完成之后同级文件夹中会有一个model.ckpt。 2. 加载模型 仅保存参数的情况下,加载参数时有模型的定义,代码如下: #Chapter05/05-4/4.load_model.py class ConvolutionalNeuralNetwork(torch.nn.Module): def __init__(self, num_classes=10): super(ConvolutionalNeuralNetwork, self).__init__() self.conv_layer1 = torch.nn.Sequential( torch.nn.Conv2d(1, 16, Kernel_size=3, stride=1, padding=1), torch.nn.ReLU(), torch.nn.MaxPool2d(Kernel_size=2, stride=2)) self.conv_layer2 = torch.nn.Sequential( torch.nn.Conv2d(16, 32, Kernel_size=3, stride=1, padding=1), torch.nn.ReLU(), torch.nn.MaxPool2d(Kernel_size=2, stride=2)) self.fc = torch.nn.Linear(7 * 7 * 32, num_classes) def forward(self, x): x = self.conv_layer1(x) x = self.conv_layer2(x) #将卷积层的结果拉成向量再通过全连接层 x = x.reshape(x.size(0), -1) x = self.fc(x) return x #实例化模型(可调用对象) model = ConvolutionalNeuralNetwork(10) #加载参数 model.load_state_dict(torch.load("./model.ckpt")) 在训练集上进行测试,因为此参数是从已经训练完成的模型中得到的,所以不需要训练其准确率就很高,代码如下: #Chapter05/05-4/4.load_model.py test_dataset = torchvision.datasets.MNIST(root='./data', train=False, transform=torchvision.transforms.ToTensor()) test_loader = torch.utils.data.DataLoader(dataset=test_dataset, batch_size=batch_size, shuffle=False) correct = 0 total = 0 for images, labels in test_loader: outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print('Accuracy on test set: {} %'.format(100 * correct / total)) 输出如下: Accuracy on test set: 98.52 % 假设是已经部署的模型,模型加载数据之后将会接收单张图片进行预测,代码如下: #Chapter05/05-4/4.load_model.py sample_image = test_dataset[0][0] torchvision.transforms.ToPILImage()(sample_image).show() outputs = model(sample_image.reshape(1, 1, 28, 28)) _, predicted = torch.max(outputs, 1) print(predicted) 提示: 此处维度处理看起来复杂但自己多尝试尝试便会发现其实很简单,记住二维卷积的输入必须是4维的[batch_size,channel,height,width],哪怕batch_size和channel都是1也不能省略,其原因在第7章讲解,写出对任意维度都能正常运行的函数不简洁,且可能引起不易察觉的错误。 图59待预测的图片 显示待预测的图片如图59所示。 输出如下: tensor([7]) 对只有一个元素的张量可以通过item()将其转换为Python基础数据类型,代码如下: scalar = torch.tensor([7]) scalar.item() 输出如下: 7 5.4.3加载数据 之前我们使用torchvision.datasets.MNIST获得过MNIST数据集,并把它当作一个包含训练数据的列表使用。事实上,在训练数据较少且内存足以全部加载的情况下,将训练数据放进一个列表进行遍历完全没有问题,而且内存的速度远超过硬盘。但若训练数据多起来,内存无法一次性将所有训练数据都读入,这就需要分步加载了。 PyTorch提供一个工具类torch.utils.data.Dataset,此工具类是一个抽象类,用户自定义的数据集应继承它并重写两个魔术方法: __len__: 重写此方法的类对其调用len()将会返回其元素个数。 __getitem__(index): 重写此方法的类能按序号返回一个元素。 重写了这两个方法之后,这个对象就能像列表一样使用并能通过len()获得其长度、[]取元素,这是因为Python魔术方法会在合适的时机被自动调用。而继承torch.utils.data.Dataset的作用主要是支持多线程,以及一种规范,与torch.utils.data.DataLoader配合。 5.4.4GPU加速 GPU因含有大量并行计算单元,非常适合用于深度学习模型的训练,相比CPU能加速十几倍到几十倍。在PyTorch中使用GPU需安装CUDA和对应版本的cuDNN,可参考9.3.3节和9.3.6节安装,之后卸载CPU版本的PyTorch并安装GPU版本的PyTorch,也可以直接使用含有常用环境的Colaboratory(https://colab.research.google.com/,可免费使用16GB显存的Tesla P100,需能访问谷歌)。 在PyTorch中使用GPU很简单,将数据和模型使用.cuda()转换为GPU类型,之后的运算便运行在GPU上,代码如下: #Chapter05/05-4/5.gpu_tensor.py x_data = torch.randn(100, 3, 224, 224) model = torchvision.models.resnet18() x_data = x_data.cuda() model = model.cuda() y_predict = model(x_data) 不过计算完成之后,如果需要绘图,则需要先用.cpu()将数据转换为CPU张量,否则因为两者不在同一个内存空间,从而导致调用.numpy()会报错,错误提示如下: TypeError: can't convert cuda:0 device type tensor to Numpy. Use Tensor.cpu() to copy the tensor to host memory first. 使用to(device)也可以将CPU张量转换为GPU类型,且可以写出兼容性更好的代码,在CPU版本的PyTorch中也可以正常执行,代码如下: #Chapter05/05-4/5.gpu_tensor.py device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu') model = ConvNet(num_classes).to(device) for epoch in range(num_epochs): for i, (images, labels) in enumerate(train_loader): images = images.to(device) labels = labels.to(device) #Forward pass outputs = model(images) loss = criterion(outputs, labels) #Backward and optimize optimizer.zero_grad() loss.backward() optimizer.step() 而.cuda()在CPU版本的PyTorch环境中就会报错。 5.4.5爬虫 爬虫程序能将网页中的数据保存下来,以供分析,例如训练猫狗分类器,可以爬取百度图片里猫和狗的图片。使用爬虫需遵守法律和robots.txt,不能用于抢购,不能爬取非公开的数据,也不能通过自己的渠道向外发布或倒卖爬取的数据,不能对服务器造成过大压力。在正确使用的前提下,爬虫是一种获取数据的有力手段。 1. 安装selenium selenium是一个浏览器自动化工具,可以使用脚本控制浏览器访问网页并保存我们需要的内容。使用pip install selenium可以安装selenium包,并需要配套浏览器驱动,推荐使用Chrome浏览器+Chrome driver,Chrome浏览器可在https://www.google.cn/Chrome/下载,安装完成后单击右上角的3个点→设置→关于Chrome可以看到当前安装的Chrome的版本,如图510所示。 图510查看Chrome版本 接着前往http://npm.taobao.org/mirrors/Chromedriver/下载对应版本的Chrome driver,笔者使用的是Windows平台所以选择Chromedriver_win32.zip,如图511所示。 图511下载Chrome driver 解压下载的压缩包之后可以得到一个可执行文件Chromedriver.exe,selenium驱动Chrome浏览器就需要它,将其复制到与Python脚本同级的目录(或将其复制到系统环境变量Path的路径下,这样在所有项目中均可被检索到,推荐放置于Python安装目录下的Scripts文件夹,如在笔者的计算机上为C:\Python\Scripts),这样安装selenium就完成了。 2. 导入selenium 首先需要使用import selenium.webdriver as webdriver导入selenium,然后创建webdriver.Chrome的一个对象,再调用browser.get(网址),程序便会启动一个Chrome浏览器,访问指定的网址,代码如下: #Chapter05/05-4/6.selenium.py import selenium.webdriver as webdriver browser = webdriver.Chrome() browser.get("http://www.baidu.com") 这样便会打开百度的首页。 3. 定位网页中的元素 网页是一段HTML格式的文档,右击网页源代码可以查看,但更常用的方式是按F12键(或设置→更多工具→开发者工具)打开开发者工具,单击元素选取按钮,在网页上单击一个元素便可定位其在源码中的位置,快捷键为Ctrl+Shift+C,如图512所示。 图512Chrome开发者工具 HTML文本是由HTML命令组成的描述性文本(可以类比编程语言),HTML命令可以是说明文字、图形、动画、声音、表格、链接等。它的每个尖括号对都可类比为在Python中创建的一个对象,如
是一个标题,是一个按钮等,它们的属性通过属性名=属性值的方式在尖括号中给出,例如就是声明并创建了一个按钮,其id属性值为commit。 浏览器下载到这些HTML文本之后需要将其渲染为页面, 想从中提取数据有两种方式,一种方式是直接按照字符串的方式解析,这种方式速度快但是较烦琐; 另一种方式是按照HTML的语法解析,例如XPATH和BeautifulSoup。selenium对这两种均支持。 特别地,如果一个页面中的元素有id属性,即在页面上唯一(这是一种约定,名为id的元素一个页面只有一个),我们查看百度首页的源码可以发现搜索框具有id "kw",搜索按钮具有id 'su',因此可以用id查找它们并进行对应的操作。 browser.find_element_by_id("kw").send_keys("AI") browser.find_element_by_id('su').click() 受selenium控制的浏览器将会向百度搜索框键入AI并单击“搜索”按钮。 5.4.6GUI编程 因为到目前为止使用的都是PyTorch提供的数据集,因此可能忽略了一个问题,那就是数据如何标注。对于图像分类这样的问题,建立一个文档记录每张图片的类别尚且是可行的,但若是物体定位或识别的任务,就应该使用一个用于数据标注的小工具。 以物体定位为例,要求标注程序有一个能够显示正在标注的图片的窗口,并允许用户单击鼠标在图片上画出方框,并保存图片名称和方框的坐标到文档中的一行。 这里介绍Qt框架的简单使用。Qt是目前最流行的跨平台GUI(图形化用户界面)框架之一,开源并且对个人免费。Qt提供Python接口,可以使用pip install pyqt5安装PyQt,若需要IDE的智能提示还需要安装PyQt5stubs。 1. PyQt的设置 此步不是必需的,但如果下面直接使用PyQt出现错误,则需要返回这里设置一个环境变量QT_PLUGIN_PATH,该环境变量的值为PyQt安装目录下plugins文件夹的路径,通常为Python安装目录下的\Lib\sitepackages\PyQt5\Qt\plugins,如在笔者的计算机上为C:\Python\Lib\sitepackages\PyQt5\Qt\plugins,找到该目录的路径可使用setx命令设置环境变量(Windows平台,需以管理员权限运行PowerShell,快捷键Win+X,A),如在笔者的计算机上设置环境变量命令如下: setx /m QT_PLUGIN_PATH "C:\Python\Lib\site-packages\PyQt5\Qt\plugins" 或右击“此计算机”→属性→高级系统设置→环境变量→在系统变量一栏添加一个系统变量,如图513所示。 图513设置环境变量 之后重启计算机便可以正常使用PyQt了。 2. 第一个PyQt程序 按照面向对象的思想,一个应用程序应该是一个类的对象,提供管理这个窗体的函数,在Qt中,表示一个应用程序的类为QApplication,它接收命令行参数,调用exec_时进入一个接收事件的死循环以便让程序不会立即执行完退出,而是等待用户交互。一个窗体也应该是一个对象,提供管理这个窗体的函数,在Qt中为一个QWidget。QWidget创建之后需要调用show()显示,否则它们只运行在内存中(和matplotlib的figure需要调用show()类似)。 一个最简单PyQt程序,代码如下: #Chapter05/05-6/1.pyqt5_hello_world.py import sys from PyQt5.QtWidgets import QApplication, QWidget application = QApplication(sys.argv) main_widget = QWidget() main_widget.show() main_widget.setWindowTitle("你好,Qt!") application.exec_() 执行上述代码将会出现一个空窗口,标题栏为文字“你好,Qt!”,如图514所示。 图 514第一个Qt程序 QApplication执行事件循环,同时可以向其询问窗口的分辨率(primaryScreen()获得主屏幕,.screens()获得所有屏幕),通过resize方法进行窗体的大小设置,以保证在不同分辨率的屏幕中外观相同,代码如下: main_widget.resize(application.primaryScreen().size()*0.7) 3. 窗体和控件 不只一个“窗口”是一个QWidget,按钮、输入框等“控件”在Qt中也是QWidget的子类,不过因为它们通常不会单独存在,需要指定它们依附的父窗体,可以通过setParent方法指定父窗体,也可以在构造函数中传入父窗体。窗体可以通过resize和move修改大小和位置,也可以通过setGeometry同时修改大小和位置。需要注意的是,窗体位置指的是窗体左上角的位置(而不是中间位置) 和在PyTorch中构建神经网络继承torch.nn.Module类似,在PyQt中构建窗体通常继承QWidget,代码如下: #Chapter05/05-6/2.pyqt5_qwidget.py import sys from PyQt5.QtWidgets import QApplication, QWidget, QPushButton class LabelToolWidget(QWidget): def __init__(self): super().__init__() self.resize(application.primaryScreen().size()*0.7) button = QPushButton(self) button.setParent(self) self.show() if __name__ == '__main__': application = QApplication(sys.argv) main_widget = LabelToolWidget() application.exec_() 注意,这里只调用了LabelToolWidget的show()方法,而没有调用button的show()方法,但button指定了父窗体,当父窗体show()时,它也会一起show(); 当父窗体销毁时,它也销毁。 除了通过创建对象的方式创建窗体外,Qt也提供了一些静态方法创建临时使用的窗体,例如无按钮的提示框QMessageBox.about和QMessageBox.warning,参数为父窗体、标题和内容,代码如下: main_widget = QWidget() main_widget.show() QMessageBox.about(main_widget, "提示", "这是一个消息框") QMessageBox提供有按钮的对话框,代码如下: main_widget = QWidget() main_widget.show() result = QMessageBox.question(main_widget, "覆盖存档", "是否覆盖到此存档?") 这里的result是一个Qt规定的数,Yes为16384,No为65536,通常需要与Qt给出的标准值比对以便确定结果,代码如下: result = QMessageBox.question(main_widget, "覆盖存档", "是否覆盖到此存档?") if result == QMessageBox.Yes: QMessageBox.about(main_widget, "保存", "保存成功") else: QMessageBox.about(main_widget, "取消", "取消保存") 此外,常用的文件对话框为QFileDialog,其静态方法getExistingDirectory会弹出一个文件对话框让用户选择一个路径,返回用户选择的文件夹的路径,代码如下: path = QFileDialog.getExistingDirectory() print(path) 输出如下: C:/Users/张伟振/PyCharmProjects/pythonProject/Chapter05/04-4 此外,也有QFileDialog.getOpenFileNames()等选择文件的方法。 虽然在这些静态方法的参数里父控件是第一个参数,但在QPushButton、QLabel等控件的构造函数中,父控件往往是最后一个参数,代码如下: button_open = QPushButton("按钮", main_widget) 不过,如果直接属于主窗口,可以将其作为类成员,这样就不必指定父对象了,代码如下: self.button_open = QPushButton("按钮") 4. 回调 使用connect传给按钮一个函数,当它被单击的时候调用这个函数,代码如下: #Chapter05/05-6/3.button.py import sys from PyQt5.QtWidgets import QWidget, QMessageBox, QApplication, QPushButton class LabelToolWidget(QWidget): def __init__(self): super().__init__() self.resize(application.primaryScreen().size()*0.7) button = QPushButton(self) button.clicked.connect(self.on_button_click) self.show() def on_button_click(self): QMessageBox.about(self, "提示", "你单击了按钮") if __name__ == '__main__': application = QApplication(sys.argv) main_widget = LabelToolWidget() sys.exit(application.exec_()) 单击按钮则会弹出消息框“你单击了按钮”。 5. 主菜单 若继承自QMainWindow,则可调用self.statusBar()、self.menuBar()、self.addToolBar获得窗口的状态栏、菜单栏和工具栏(self.statusBar()和self.menuBar()在一个窗体中只能有一个,第一次获取会创建,后续返回当前的那个,self.addToolBar调用一次创建一个工具栏)。使用setCentralWidget设置窗体主要区域,其会拉伸至填满主窗体。 1) 使用状态栏显示消息 self.statusBar().showMessage()可以在状态栏上显示一条消息,代码如下: self.statusBar().showMessage("就绪") 可以在参数中追加消息显示的时间(单位为毫秒),代码如下: self.statusBar().showMessage("就绪", 5000) 2) 使用工具栏容纳Action 使用self.addToolBar创建一个新的工具栏,代码如下: self.toolBar = self.addToolBar("常用功能") 工具栏添加的是一个个Action,一个Action是应用中的一个功能,例如打开、保存、另存为等,Action可以由多种方式触发(triggered): 单击工具栏的对应按钮、使用快捷键、单击菜单栏中的对应按钮。 例如创建Action,其触发回调绑定为QApplication的quit方法,代码如下: self.toolBar = self.addToolBar("常用功能") self.quitAction = QAction("关闭程序") self.quitAction.triggered.connect(app.quit) self.toolBar.addAction(self.quitAction) 6. 图片读取与显示 显示图片可以使用控件QLabel的setPixmap函数,该方法接收一个QPixmap对象,该对象可以加载一张图片,也可以直接使用QPixmap接收一个路径的构造函数创建对象并加载图片,代码如下: #Chapter05/05-6/4.open_image.py class MyMainWidget(QMainWindow): def __init__(self): super(MyMainWidget, self).__init__() self.resize(application.primaryScreen().size()*0.7) self.label = QLabel() self.setCentralWidget(self.label) self.label.setAlignment(Qt.AlignCenter) self.toolBar = self.addToolBar("常用功能") self.actionOpenImage = QAction("打开图片") self.actionOpenImage.triggered.connect(self.openImage) self.toolBar.addAction(self.actionOpenImage) self.show() def openImage(self): pixmap = QPixmap(QFileDialog.getOpenFileName()[0]) self.label.setPixmap(pixmap) 要让使用QLabel的图片居中对齐,需要调用其setAlignment方法,该方法的参数为一个枚举值Qt.AlignCenter,需要导入PyQt5.QtCore. Qt类获取这个枚举值,代码如下: from PyQt5.QtCore import Qt self.label.setAlignment(Qt.AlignCenter) 效果如图515所示。 图515使用QLabel显示图片 7. 事件 PyQt5中的事件类似Python中的魔术方法,会在特定的时候被自动执行,例如mousePressEvent方法控件会在被单击的时候执行,mouseMoveEvent会在单击并按住按键拖动时执行(若需要在不按下时追踪鼠标位置则需要设置setMouseTracking(true)),mouseReleaseEvent会在单击抬起时执行。 在鼠标按下时记录此时鼠标的位置作为矩形框的左上角,移动时将鼠标的位置作为右下角,在paintEvent事件中使用画家(painter)画一个矩形,代码如下: #Chapter05/05-6/5.label_image.py class ImageView(QLabel): def __init__(self): super(ImageView, self).__init__() self.start_x = -1 self.start_y = -1 self.end_x = -1 self.end_y = -1 def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: self.start_x, self.start_y = event.x(), event.y() self.update() def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: self.end_x, self.end_y = event.x(), event.y() self.update() def paintEvent(self, event: QtGui.QPaintEvent) -> None: super().paintEvent(event) painter = QPainter(self) pen = QPen(Qt.red, 3) painter.setPen(pen) if self.end_x != -1 and self.end_y != -1: painter.drawRect(self.start_x, self.start_y, self.end_x - self.start_x, self.end_y - self.start_y) 这种继承并重写方法是一种常见的自定义控件的方式,主窗口创建一个ImageView对象,可以当作QLabel使用,但其能响应框选操作,代码如下: class MyMainWidget(QMainWindow): def __init__(self): super(MyMainWidget, self).__init__() self.resize(application.primaryScreen().size() * 0.7) self.imageView = ImageView() self.imageView.setAlignment(Qt.AlignCenter) self.setCentralWidget(self.imageView) self.toolBar = self.addToolBar("常用功能") self.actionOpenImage = QAction("打开图片") self.actionOpenImage.triggered.connect(self.openImage) self.toolBar.addAction(self.actionOpenImage) self.imageShowing = None self.show() def openImage(self): self.imageShowing = QPixmap(QFileDialog.getOpenFileName()[0]) self.imageView.setPixmap(self.imageShowing) 效果如516所示。 图516标注框