【Intel oneAPI 校企合作课程】基于ResNet-50的肺炎检测模型

团队名称——西石油的蝴蝶(团队成员:贾文俊、邓鹏;指导老师:李艳)

目录

1.项目介绍

1.1.问题引入

1.2数据集

1.2.1.初始数据集介绍

1.2.2.数据预处理(数据增强技术)

1.3.Intel oneAPI

1.3.1.oneAPI介绍

1.3.2.Intel Extension for Pytorch

1.4.ResNet-50网络

1.4.1.ResNet网络

1.4.2.ResNet-50网络搭建

2.实验步骤

2.1.导入所需要的包

2.2.设置变量、常量和超参数

2.3.数据处理和装载

2.4.网络搭建

2.4.1.网络模型

2.4.2.网络实例化

2.5.模型训练

2.6.模型预测及模型评估

3.总结


1.项目介绍

1.1.问题引入

随着 CT(计算机断层扫描)和MRI(磁共振成像)的发展,医生需要查看的医学影像极大地增加。随着深度学习在计算机视觉上的突破,将其用于诊断成像检测已成为一种实用且高效的方式。这种方式可以提高临床医生的工作效率,增强成像解释,并协助异常检测、鉴别诊断和工作列表优先级排序,对检测患者的肺部病变具有重要价值。

1.2数据集

1.2.1.初始数据集介绍

该​​​​数据集分为 3 个文件夹(train训练集、test测试集、val验证集),并包含每个图像类别(肺炎、正常)的子文件夹。共有 5863 张 X 光图像(JPEG)和 2 个类别(肺炎、正常)。

其中,

  • train数据集有1341张正常图像,3875张肺炎图像。
  • val数据集有8张正常图像,8张肺炎图像。
  • test数据集有234张正常图像,390张肺炎图像。

在分析胸部 X 光图像时,首先对所有胸部 X 光图像进行质量控制筛查,去除所有低质量或无法读取的扫描图像。然后,由两名专家医师对图像进行诊断分级,然后再对人工智能系统进行训练。为了避免任何分级错误,验证集还由第三位专家进行检查。

1.2.2.数据预处理(数据增强技术)

数据增强技术是一种在处理不同类别的数据量不同时的一种强力手段。在初始数据集中正常图像明显比肺炎图像的样本更少,通过对少量样本进行一些数据增强的方法,可以降低样本不均衡的比例,使得模型能够更好地学习。

为了避免过拟合的情况,我们并没有使用使用太多数据增强。这里仅对train数据集增强2倍,以确保样本量均衡。

通常来说,train数据集用于训练模型的参数,而val数据集用于调整模型的超参数,但由于我们的val数据集中样本太少(各类别只有8张),每次验证时,均全验证正确,无法使用,故我们将其做了5倍数据增强,放入train集中。

最后的train集中有 4063(4023(1041*3)+40(8*5))张正常图像 和 3915(3875+40)张肺炎图像

import os
import random
import cv2
import numpy as np
import shutil
    
'''参数设定'''
input_folder = r"/202131771231/intel/dataset/train/NORMAL"
output_folder = r"/202131771231/intel/DataAugmentation/trian/NORMAL"
prob = 0.5
mutiplier = 3

def load_images_from_folder(folder):
    images = []
    for filename in os.listdir(folder):
        img = cv2.imread(os.path.join(folder, filename))
        if img is not None:
            images.append(img)
    return images

def save_images_to_folder(images, folder):
    for i, img in enumerate(images):
        cv2.imwrite(os.path.join(folder, f"augmented_{i}.png"), img)


def random_brightness(img, low=0.8, high=1.2):
    ''' 随机改变亮度(0.8~1.2) '''
    x = random.uniform(low, high)
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    hsv[:, :, 2] = np.clip(hsv[:, :, 2] * x, 0, 255).astype(np.uint8)
    img = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
    return img


def random_contrast(img, low=0.8, high=1.2):
    ''' 随机改变对比度(0.5~1.5) '''
    x = random.uniform(low, high)
    img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    img[:, :, 1] = np.clip(img[:, :, 1] * x, 0, 255).astype(np.uint8)
    img = cv2.cvtColor(img, cv2.COLOR_HSV2BGR)
    return img


def random_color(img, low=0.9, high=1.1):
    ''' 随机改变饱和度(0.5~1.5) '''
    x = random.uniform(low, high)
    img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    img[:, :, 1] = np.clip(img[:, :, 1] * x, 0, 255).astype(np.uint8)
    img = cv2.cvtColor(img, cv2.COLOR_HSV2BGR)
    return img

def random_sharpness(img, low=0.9, high=1.1):
    ''' 随机改变清晰度(0.8~1.5) '''
    x = random.uniform(low, high)
    kernel = np.array([[0, -1, 0],
                       [-1, 5 + x, -1],
                       [0, -1, 0]])
    img = cv2.filter2D(img, -1, kernel)
    return img

def random_flip(img, prob=0.5):
    ''' 随机翻转图像(p=0.5) '''
    if random.random() < prob:
        img = cv2.flip(img, 1)  # 左右翻转
    # if random.random() < prob:   # 上下翻转
    #     img = cv2.flip(img, 0)
    return img

def random_rotate(img, low=-30, high=30):
    ''' 随机旋转图像(-30~30) '''
    angle = random.choice(range(low, high))
    rows, cols, _ = img.shape
    rotation_matrix = cv2.getRotationMatrix2D((cols / 2, rows / 2), angle, 1)
    img = cv2.warpAffine(img, rotation_matrix, (cols, rows))
    return img

def random_noise(img, low=0, high=0):
    ''' 随机加高斯噪声(0~10) '''
    sigma = np.random.uniform(low, high)
    noise = np.random.randn(img.shape[0], img.shape[1], 3) * sigma
    img = img + np.round(noise).astype('uint8')
    # 将矩阵中的所有元素值限制在0~255之间:
    img[img > 255], img[img < 0] = 255, 0
    return img

def apply_data_augmentation(img,prob=0.5,mutiplier=2):
    ''' 叠加多种数据增强方法 '''
    opts = [random_brightness, random_contrast, random_color, random_flip,
            random_rotate, random_noise, random_sharpness,]  # 数据增强方法
    random.shuffle(opts)
    imgs = []
    for i in range(mutiplier): 
        img_copy = img
        for opt in opts:
            img_copy = opt(img_copy) if random.random() < prob else img_copy     # 处理图像
        imgs.append(img_copy)
    
    return imgs


def main():

    if os.path.exists(output_folder):
        # 如果存在,则清空文件夹
        shutil.rmtree(output_folder)
    # 创建新的空文件夹
    os.makedirs(output_folder)
    
    # 从输入文件夹加载图像
    original_images = load_images_from_folder(input_folder)
    # print(original_images)
    # 对每个图像应用数据增强

    augmented_images = [apply_data_augmentation(img=img,prob=prob,mutiplier=mutiplier-1) for img in original_images]

    ######## 展平增强后的图像列表
    flattened_augmented_images = [img for sublist in augmented_images for img in sublist]
    
    # 将增强后的图像保存到输出文件夹
    save_images_to_folder(flattened_augmented_images, output_folder)

if __name__ == "__main__":
    main()

1.3.Intel oneAPI

1.3.1.oneAPI介绍

Intel oneAPI是一个跨平台、可移植的开发工具集,它支持多种处理器架构,包括英特尔 CPU、GPU、FPGA 和其他加速器。这套工具集旨在为开发者提供编写高性能并行应用程序的能力,并简化跨平台开发的过程,使开发者能够更轻松地利用不同处理器架构的优势。

同时,oneAPI还提供了一套统一的编程模型和工具,使开发人员能够轻松地利用不同类型的处理器和加速器来加速应用程序的执行。其目标是实现代码的可移植性和可扩展性,使开发人员能够更高效地利用现代硬件。

1.3.2.Intel Extension for Pytorch

我们将使用的主要组件是英特尔® PyTorch* 扩展 。Intel® Extension for PyTorch* 通过最新的功能优化扩展了 PyTorch*,从而进一步提升了相关硬件的性能。这个python拓展有以下优势:

  1. 易于使用的 Python API:用户只需进行少量代码更改即可获得性能优化,例如图形优化和运算符优化。每部分优化通常只需要修改 2 到 3 行代码。

  2. Channels Last:在 Intel® Extension for PyTorch* 中,大多数关键 CPU 运算符已启用 NHWC 内存格式。与默认的 NCHW 内存格式相比,channels_last (NHWC) 内存格式可以进一步加速卷积神经网络。

  3. 自动混合精度 (AMP):针对 CPU 的自动混合精度 (AMP) 和 BFloat16 的支持以及运算符的 BFloat16 优化已在该扩展中大量启用。

  4. 图形优化:为了利用 torchscript 进一步优化性能,该扩展支持常用算子模式的融合,例如 Conv2D+ReLU、Linear+ReLU 等。

  5. 运算符优化:该扩展还优化了运算符并实现了多个定制运算符(例如Mask R-CNN 中定义了 ROIAlign 和 NMS)以提高性能。

1.4.ResNet-50网络

1.4.1.ResNet网络

与传统的网络结构相比,ResNet 的主要贡献是发现了神经网络的退化现象,并针对退化现象提出了短路连接shortcut connection,极大地消除了深度过大的神经网络的训练困难问题

在传统的网络结构中,如果网络已经达到了最优解,继续训练可能会导致权重参数更新后带来更大的误差。这是因为传统网络使用损失函数来指导权重参数的更新,损失函数会使得权重参数朝着减小误差的方向进行调整。然而,当网络已经达到最优解时,继续训练可能会导致权重参数偏离最优值。为了解决这个问题,我们引入identity操作。通过在达到最优解时将F(x)设置为等于x,确保权重参数不会继续更新造成更大的误差。这样,权重参数至少可以保持在之前的最优水平,并且可以加快网络的收敛速度。

网络整体结构如图:

如下图所示,shortcut connection的添加,使得第二层激活函数的输入由原来的输出F(x)变为了F(x)+x。

上图即为一个残差单元的模型,它可以表示为:

其中,𝑥𝑙和𝑥𝑙+1分别表示第l个残差块的输入和输出,事实上每个残差单元一般包含多层结构。F是残差函数,h表示恒等映射,f是激活函数。

从浅层 l 到深层 L 的学习特征为:

利用链式法则,可以求导得到反向过程的梯度:

1.4.2.ResNet-50网络搭建

首先搭建BasicBlock和BottleBlock:搭建两者思路大致相似,均为先构造shortcut connection模块,再重复利用。二者区别在于,BasicBlock 包含两个 3*3 的卷积层,而BottleNeck 包含三个卷积层,第一个 1*1 的卷积层用于降维,第二个 3*3 层用于处理,第三个 1*1 卷积层用于升维。如此操作减少了计算量。

下图为BasicBlock:

下图为BottleBlock:

搭建ResNet:首先搭建第一层卷积层(Conv2d),输入图像经过卷积操作,提取特征。再添加批量归一化层(BatchNorm2d):对卷积层的输出进行归一化处理,加速训练过程并提高模型性能。接着调用 make_layers 函数,根据 BasicBlock 和 BottleNeck 类进行结构的构造。之后再添加自适应平均池化层,可以根据输入特征图的大小自动调整池化窗口的大小,以适应不同的输入尺寸,这样可以在一定程度上提高模型的泛化能力。最后,将最后一个残差块的输出展平,并通过全连接层进行分类任务。

最后按照如下定义调用即可。(完整代码在2.4.1.网络模型中)

def ResNet50(in_channels, num_classes):

    return ResNet(Bottleneck, [3, 4, 6, 3], in_channels, num_classes)

2.实验步骤

2.1.导入所需要的包

训练和测试所需:

import torch.optim as optim
import torch
import torch.nn as nn
import torch.nn.parallel
import torch.optim
import torch.utils.data
import torch.utils.data.distributed
import torchvision.transforms as transforms
import torchvision.datasets as datasets
from torch.autograd import Variable
"""下面为自己定义的函数"""
# from EfficientNetv2 import efficientnetv2_m
from ResNet02 import ResNet50
""" Import intel_extension_for_pytorch 导入英特尔对pytorch优化的包 """
import intel_extension_for_pytorch as ipex

2.2.设置变量、常量和超参数

# 设置全局参数
modellr = 1e-4 # 初始学习率
BATCH_SIZE = 8 # 太大可能超内存
EPOCHS = 30
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

2.3.数据处理和装载

# 数据预处理
 
transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize(mean = [0.5],std = [0.5]),
    transforms.Grayscale(num_output_channels = 1)
])
transform_test = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize(mean = [0.5],std = [0.5]),
    transforms.Grayscale(num_output_channels = 1)
])
# 读取数据
# dataset_train = datasets.ImageFolder('dataset/train', transform)
dataset_train = datasets.ImageFolder('./DataAugmentation/train/', transform)
print(dataset_train.imgs)
# 对应文件夹的label
print(dataset_train.class_to_idx)
dataset_test = datasets.ImageFolder('./dataset/test/', transform_test)
# 对应文件夹的label
print(dataset_test.class_to_idx)
 
# 导入数据
train_loader = torch.utils.data.DataLoader(dataset_train, batch_size=BATCH_SIZE, shuffle=True)
test_loader = torch.utils.data.DataLoader(dataset_test, batch_size=BATCH_SIZE, shuffle=False)

2.4.网络搭建

2.4.1.网络模型

选用ResNet-50,这里即是2.1.所导入的包ResNet02:

import torch.nn as nn
import torch.nn.functional as F


class BasicBlock(nn.Module):
    expansion = 1

    def __init__(self, in_planes, planes, stride=1):
        super(BasicBlock, self).__init__()
        self.conv1 = nn.Conv2d(
            in_planes, planes, kernel_size=3, stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(planes)
        
        self.conv2 = nn.Conv2d(planes, planes, kernel_size=3,
                               stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(planes)

        self.shortcut = nn.Sequential()
        if stride != 1 or in_planes != self.expansion*planes:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_planes, self.expansion*planes,
                          kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(self.expansion*planes)
            )

    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out += self.shortcut(x)
        out = F.relu(out)
        return out


class Bottleneck(nn.Module):
    expansion = 4

    def __init__(self, in_planes, planes, stride=1):
        super(Bottleneck, self).__init__()
        self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=1, bias=False)
        self.bn1 = nn.BatchNorm2d(planes)
        self.conv2 = nn.Conv2d(planes, planes, kernel_size=3,
                               stride=stride, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(planes)
        self.conv3 = nn.Conv2d(planes, self.expansion *
                               planes, kernel_size=1, bias=False)
        self.bn3 = nn.BatchNorm2d(self.expansion*planes)

        self.shortcut = nn.Sequential()
        if stride != 1 or in_planes != self.expansion*planes:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_planes, self.expansion*planes,
                          kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(self.expansion*planes)
            )

    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = F.relu(self.bn2(self.conv2(out)))
        out = self.bn3(self.conv3(out))
        out += self.shortcut(x)
        out = F.relu(out)
        return out


class ResNet(nn.Module):
    def __init__(self, block, num_blocks, in_channels, num_classes):
        super(ResNet, self).__init__()
        self.in_planes = 64

        self.conv1 = nn.Conv2d(in_channels, 64, kernel_size=3,
                               stride=1, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(64)
        self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1)
        self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2)
        self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2)
        self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2)
        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
        self.linear = nn.Linear(512 * block.expansion, num_classes)

    def _make_layer(self, block, planes, num_blocks, stride):
        strides = [stride] + [1]*(num_blocks-1)
        layers = []
        for stride in strides:
            layers.append(block(self.in_planes, planes, stride))
            self.in_planes = planes * block.expansion
        return nn.Sequential(*layers)

    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.layer1(out)
        out = self.layer2(out)
        out = self.layer3(out)
        out = self.layer4(out)
        out = self.avgpool(out)
        out = out.view(out.size(0), -1)
        out = self.linear(out)
        return out


def ResNet18(in_channels, num_classes):
    return ResNet(BasicBlock, [2, 2, 2, 2], in_channels, num_classes)


def ResNet50(in_channels, num_classes):
    return ResNet(Bottleneck, [3, 4, 6, 3], in_channels, num_classes)

2.4.2.网络实例化

网络实例化(使用ipex优化):

# 设置模型
# 实例化模型并且移动到GPU
criterion = nn.CrossEntropyLoss()
# model = efficientnetv2_m()
model = ResNet50(in_channels=1, num_classes=2)
# num_ftrs = model.classifier.in_features
# model.classifier = nn.Linear(num_ftrs, 2)
model.to(DEVICE)
# 选择简单暴力的Adam优化器,学习率调低

# Invoke optimize function against the model object and optimizer object
"""利用ipex来优化"""
model, optimizer = ipex.optimize(model=model,optimizer=optimizer)
 
def adjust_learning_rate(optimizer, epoch):
    """Sets the learning rate to the initial LR decayed by 10 every 30 epochs"""
    modellrnew = modellr * (0.1 ** (epoch // 50))
    print("lr:", modellrnew)
    for param_group in optimizer.param_groups:
        param_group['lr'] = modellrnew
 

2.5.模型训练

训练模型,并保存30个epoch中效果最好的epoch的模型文件。

# 定义训练过程  
def train(model, device, train_loader, optimizer, epoch):  
    model.train()  
    sum_loss = 0  
    total_num = len(train_loader.dataset)  
    print(total_num, len(train_loader))  
    for batch_idx, (data, target) in enumerate(train_loader):  
        data, target = data.to(device), target.to(device)  
        output = model(data)  
        loss = criterion(output, target)  
        optimizer.zero_grad()  
        loss.backward()  
        optimizer.step()  
        print_loss = loss.item()  
        sum_loss += print_loss  
        if (batch_idx + 1) % 50 == 0:  
            print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(  
                epoch, (batch_idx + 1) * len(data), len(train_loader.dataset),  
                       100. * (batch_idx + 1) / len(train_loader), loss.item()))  
    ave_loss = sum_loss / len(train_loader)  
    print('epoch:{},loss:{}'.format(epoch, ave_loss))  
    return ave_loss  
  
# 验证过程  
def val(model, device, test_loader):  
    model.eval()  
    test_loss = 0  
    correct = 0  
    total_num = len(test_loader.dataset)  
    print(total_num, len(test_loader))  
    with torch.no_grad():  
        for data, target in test_loader:  
            data, target = data.to(device), target.to(device)  
            output = model(data)  
            loss = criterion(output, target)  
            _, pred = torch.max(output.data, 1)  
            correct += torch.sum(pred == target)  
            print_loss = loss.item()  
            test_loss += print_loss  
        correct = correct.item()  
        acc = correct / total_num  
        avgloss = test_loss / len(test_loader)  
        print('\nVal set: Average loss: {:.4f}, Accuracy: {}/{} ({:.0f}%)\n'.format(  
            avgloss, correct, len(test_loader.dataset), 100 * acc))  
        return avgloss, acc  
  
# 训练过程  
best_val_loss = float('inf')  # 初始化最好的验证损失为无穷大  
best_epoch = 0  # 记录最好的epoch  
for epoch in range(1, EPOCHS + 1):  
    adjust_learning_rate(optimizer, epoch)  
    train_loss = train(model, DEVICE, train_loader, optimizer, epoch)  
    val_loss, val_acc = val(model, DEVICE, test_loader)  
    if val_loss < best_val_loss:  # 如果当前验证损失比之前的要好,保存这个epoch的模型  
        best_val_loss = val_loss  
        best_epoch = epoch  
        torch.save(model, 'best_model.pth')  
print('Best model at epoch', best_epoch, 'with validation loss', best_val_loss)

2.6.模型预测及模型评估

我们使用了F1分数和推理时间作为评价指标:

通常来说,医学上,患病为阳性,即1;正常为阴性,即0。

 相关概念
真实1真实0
预测1真阳性TP假阳性FP
预测0假阴性FN真阴性TN

精确率(precision),Precision = TP/(TP+FP)。                         

   

召回率(recall),也叫查全率,Recall = TP/(TP+FN)。

                   

F1分数(F1-Score),为精确率和召回率的调和平均数,2*Precision*Recall/(Precision+Recall)。

我们得到的F1分数为:

  • TP: 386         FP: 101          FN: 4          TN: 133
  • Precisision: 0.7926              Recall: 0.9897
  • F1 Score: 0.8803

我们得到的推理时间为:

  • 232.1674s(on CPU)平均每张0.3721秒
  • 14.4832s (on GPU) 平均每张0.0232秒
import torch
import torchvision.transforms as transforms
import torchvision.datasets as datasets
from torch.autograd import Variable
import time

TP,FP,FN,TN = 0,0,0,0

classes = ('NORMAL', 'PNEUMONIA')
transform_test = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize([0.5], [0.5]),
    transforms.Grayscale(num_output_channels=1)
])
# DEVICE = torch.device("cpu")
DEVICE = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
model = torch.load("model.pth")
model.eval()
model.to(DEVICE)

dataset_test = datasets.ImageFolder('./test2/', transform_test)
print(len(dataset_test))


start_time = time.time()

for index in range(len(dataset_test)):
    item = dataset_test[index]
    img, label = item
    
    img.unsqueeze_(0)
    data = Variable(img).to(DEVICE)
    
    output = model(data)
    _, pred = torch.max(output.data, 1)

    if index < 234:
        if classes[pred.data.item()] == "NORMAL":
            print("TN")
            TN+=1
        if classes[pred.data.item()] == "PNEUMONIA":
            print("FP")
            FP+=1
    else:
        if classes[pred.data.item()] == "NORMAL":
            print("FN")
            FN+=1
        if classes[pred.data.item()] == "PNEUMONIA":
            print("TP")
            TP+=1

    print('Image Name:{}, predict:{}'.format(dataset_test.imgs[index][0], classes[pred.data.item()]))

end_time = time.time()
elapsed_time = end_time - start_time
print(f"Total inference time: 0.0674 seconds")

Precision = 1.0*TP/(TP+FP)
Recall = 1.0*TP/(TP+FN)
F1 = 2*Precision*Recall/(Precision+Recall)
print(f"TP: {TP}\tFP: {FP}\tFN: {FN}\tTN: {TN}")
print(f"F1 Score: {F1}")

3.总结

本次Intel oneAPI校企合作课程中,我们遇到了很多问题。包括模型的选择,数据的处理,精确率不高、通道数不匹配、torch类型不匹配等问题。由于数据增强后的数据量和网络模型较大,很容易溢出内存,可以修改batch_size值来缓解这个问题。我们尝试过自行搭建CNN网络、EfficientNet系列网络(包括B2、B3,时间更快,但是F1值略低于ResNet),最终在F1值和推理时间的权衡下选择了ResNet作为主要的网络模型。

我们发现,在某些情况下,尤其是使用CPU时,通过Intel oneAPI的相关组件来优化pytorch,会得到比较不错的效果。期待未来oneAPI在pytorch上的更多优化。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值