构建一个用于图像识别的卷积神经网络(CNN)——ResNet实践

本文内容旨在说明如何构建一个用于图像分类的神经网络,并对模型进行评估,预测。
这里使用公开数据集与resnet18经行演示。

一、数据准备与预处理

1、收集数据集

选择或收集包含所需图像类别的数据集。数据集应包含足够的图像样本,以覆盖各种可能的图像变化(如光照、角度、遮挡等)。

本文采用的是源于kaggle的乳腺超声图像公开数据集
链接:数据集链接

benign
malignant
normal

2、图像预处理

图像缩放:将图像缩放到统一的大小,以适应CNN的输入要求。
归一化:将图像的像素值归一化到一定的范围内(如0-1或-1到1),以加快训练速度和提高模型性能。
数据增强(可选):通过旋转、翻转、缩放、裁剪等方式增加数据集的多样性,以提高模型的泛化能力。

这里使用的是将标记图与原始图经行叠加处理

input_dir = 'Dataset_BUSI_with_GT'  # 定义输入目录
output_dir = 'working/OverlayedImages'  # 定义输出目录,保存叠加图像

labels = ['benign', 'malignant', 'normal']
for label in labels:
    os.makedirs(os.path.join(output_dir, label), exist_ok=True)  # 创建输出目录

def overlay_and_save(image_path, mask_path):  # 定义图像叠加和保存函数
    try:
        if os.path.exists(image_path) and os.path.exists(mask_path):
            image = Image.open(image_path)
            mask = Image.open(mask_path)

            if image.mode != mask.mode:
                mask = mask.convert(image.mode)

            if image.size != mask.size:
                image = image.resize(mask.size)

            overlayed = Image.blend(image, mask, alpha=0.5)

            label = os.path.basename(os.path.dirname(image_path))
            output_path = os.path.join(output_dir, label, os.path.basename(image_path))
            overlayed.save(output_path)
        else:
            pass
    except Exception as e:
        print(f"An error occurred for: {image_path} or {mask_path}. Error: {str(e)}")

for label in labels:  # 遍历目录并处理图像
    label_dir = os.path.join(input_dir, label)
    if os.path.isdir(label_dir):
        for image_filename in os.listdir(label_dir):
            if image_filename.endswith('.png'):
                image_path = os.path.join(label_dir, image_filename)
                mask_filename = image_filename.replace('.png', '_mask.png')
                mask_path = os.path.join(label_dir, mask_filename)
                overlay_and_save(image_path, mask_path)

print("Overlayed images have been saved to /kaggle/working/OverlayedImages directory.")

详解
定义输入和输出目录
input_dir = ‘Dataset_BUSI_with_GT’: 输入目录,包含原始图像和对应的掩码图像。
output_dir = ‘working/OverlayedImages’: 输出目录,用于保存叠加后的图像。
创建输出目录
labels = [‘benign’, ‘malignant’, ‘normal’]: 三种标签类别,分别表示良性、恶性和正常。
os.makedirs(os.path.join(output_dir, label), exist_ok=True): 为每个标签创建对应的输出目录。如果目录已经存在,则不会报错。
定义图像叠加和保存函数overlay_and_save:
检查文件存在性:
检查图像文件和掩码文件是否存在,确保后续操作能够进行。
读取图像和掩码:
使用Image.open函数打开图像和掩码文件。
模式匹配:
如果图像和掩码的模式(如RGB, L等)不匹配,将掩码转换为与图像相同的模式。
尺寸匹配:
如果图像和掩码的尺寸不一致,将图像调整为掩码的尺寸。
图像叠加:
使用Image.blend函数将图像和掩码按指定的透明度alpha=0.5进行叠加。
保存叠加图像:
根据图像所属的类别(从其父目录获取),将叠加后的图像保存到对应的输出目录。
错误处理:
如果在处理过程中发生任何错误,打印错误信息并继续处理下一个文件。
遍历目录并处理图像
遍历标签类别:
对每个类别(benign, malignant, normal)的文件夹进行遍历。
找到并处理图像文件:
对于每个文件,如果它是以.png结尾的图像文件,则查找对应的掩码文件(文件名类似,但结尾为_mask.png),然后调用overlay_and_save函数进行处理。
输出信息
当所有图像处理完成后,打印信息,表示叠加后的图像已保存到指定的输出目录。

叠加后的图像展示
叠加后图像展示

叠加处理有以下优点
突出特征:标记后的图像通常会对目标物体进行标注,如边界框、关键点等。将这些标注信息叠加到原始图像上,可以使得目标特征在视觉上更加突出。这样,在训练过程中,网络模型能够更容易地注意到这些特征,从而加强对这些特征的学习。
引导注意力:叠加后的图像通过明确指示目标物体的位置和范围,引导网络模型在训练时将更多的注意力集中在这些关键区域上。这有助于模型更快地学习到有效的特征表示,提高检测或识别的精度。

3、划分数据集

将数据集划分为训练集、验证集和测试集。通常的比例为70%训练集、15%验证集和15%测试集,但具体比例可根据实际情况调整。

这里采用通常比例

def data_processing(subdirectories=['train', 'validation', 'test']):
    # 数据预处理,数据增强正则化,数据分类
    class_names = ['malignant', 'normal', 'benign']
    minority_classes = ['malignant', 'normal']

    minority_class_transforms = transforms.Compose([
        transforms.RandomHorizontalFlip(p=0.9),
        transforms.RandomRotation(15, expand=False),
        transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1),
    ])

    data_transforms = {
        'train': transforms.Compose([
            transforms.Resize(256),
            transforms.CenterCrop(224),
            transforms.RandomApply([minority_class_transforms], p=0.5),
            transforms.ToTensor(),
            transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
        ]),
        'validation': transforms.Compose([
            transforms.Resize(256),
            transforms.CenterCrop(224),
            transforms.ToTensor(),
            transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
        ]),
        'test': transforms.Compose([
            transforms.Resize(256),
            transforms.CenterCrop(224),
            transforms.ToTensor(),
            transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
        ]),
    }
    #  训练数据来源地址
    # data_dir = 'working/OverlayedImages'      # 使用标记过的数据
    data_dir = 'Dataset_BUSI_with_GT_nomask'    # 使用没有标记过的数据
    file_paths = []
    labels = []
    for label in os.listdir(data_dir):
        label_dir = os.path.join(data_dir, label)
        if os.path.isdir(label_dir):
            for image_file in os.listdir(label_dir):
                if image_file.endswith('.png') and not (image_file.endswith('_mask.png') or
                                                        image_file.endswith('_mask_1.png') or
                                                        image_file.endswith('_mask_2.png')):
                    image_path = os.path.join(label_dir, image_file)
                    labels.append(label)
                    file_paths.append(image_path)

    data = pd.DataFrame({'Image_Path': file_paths, 'Label': labels})

    # 分割数据集为训练集、验证集和测试集
    train_data, test_data = train_test_split(data, test_size=0.15, random_state=42, stratify=data['Label'])
    train_data, val_data = train_test_split(train_data, test_size=0.15, random_state=42, stratify=train_data['Label'])

    train_dir = 'working/train'
    val_dir = 'working/validation'
    test_dir = 'working/test'

    for label in class_names:
        os.makedirs(os.path.join(train_dir, label), exist_ok=True)
        os.makedirs(os.path.join(val_dir, label), exist_ok=True)
        os.makedirs(os.path.join(test_dir, label), exist_ok=True)

    # 复制图像文件到相应目录
    for _, row in train_data.iterrows():
        image_path = row['Image_Path']
        label = row['Label']
        shutil.copy(image_path, os.path.join(train_dir, label))

    for _, row in val_data.iterrows():
        image_path = row['Image_Path']
        label = row['Label']
        shutil.copy(image_path, os.path.join(val_dir, label))

    for _, row in test_data.iterrows():
        image_path = row['Image_Path']
        label = row['Label']
        shutil.copy(image_path, os.path.join(test_dir, label))

    # 打印每个类别在每个数据集中的文件数量
    for split_dir, split_name in [(train_dir, 'Train'), (val_dir, 'Validation'), (test_dir, 'Test')]:
        file_counts = {label: len(os.listdir(os.path.join(split_dir, label))) for label in class_names}
        for category, count in file_counts.items():
            print(f"{split_name} {category}: {count}")

    # 创建数据集对象
    image_datasets = {
        x: datasets.ImageFolder(root=os.path.join('working', x), transform=data_transforms[x])
        for x in subdirectories
    }

    # 创建数据加载器
    dataloaders = {
        x: DataLoader(image_datasets[x], batch_size=32, shuffle=True, num_workers=4)
        for x in subdirectories
    }

    dataset_sizes = {x: len(image_datasets[x]) for x in subdirectories}
    class_names = image_datasets['train'].classes

    return dataloaders, dataset_sizes, class_names

详解:
定义类别和数据增强
class_names: 列出所有分类标签,包括’malignant’(恶性)、‘normal’(正常)和’benign’(良性)。
minority_classes: 指定需要进行数据增强的小类,这里包括’malignant’和’normal’。
minority_class_transforms: 定义小类的图像增强方法,包括随机水平翻转、随机旋转、颜色抖动等。
定义数据增强和预处理管道
data_transforms: 为训练、验证和测试集分别定义数据预处理操作。对于训练集,还包括条件应用的少数类增强。
数据加载和过滤
data_dir: 数据的根目录,这里设置为’Dataset_BUSI_with_GT_nomask’(未标记的数据)。
file_paths和labels: 用于存储所有图像文件的路径及其对应的标签。通过遍历数据目录,将符合条件的图像文件路径和标签加入列表中。
数据集划分
使用train_test_split函数将数据分割为训练集、验证集和测试集。数据划分是基于标签的分层抽样(stratify),以确保每个子集中的标签分布与原始数据集一致。
创建目录并复制文件
创建存放训练集、验证集和测试集图像的目录,并根据标签将对应的图像文件复制到相应的子目录中。
打印每个类别的文件数量
统计并打印每个数据集(训练、验证、测试)中每个类别的图像数量,以便检查数据分布。
创建数据集对象和数据加载器
使用datasets.ImageFolder创建图像数据集对象,这些对象根据前面定义的data_transforms进行数据预处理。
使用DataLoader为每个数据集创建数据加载器,这些加载器用于在训练过程中批量加载数据。设置batch_size为32,并在训练时打乱数据(shuffle=True)。
返回值
返回dataloaders, dataset_sizes, class_names三个对象:
dataloaders: 包含训练集、验证集和测试集的加载器。
dataset_sizes: 每个子集的数据量。
class_names: 训练集中的分类标签。

二、构建CNN模型

1、定义模型结构:

使用深度学习框架(如TensorFlow、PyTorch、MATLAB等)中的工具或函数定义CNN模型结构。
CNN模型通常包括多个卷积层、池化层、激活函数(如ReLU)、全连接层和输出层(如Softmax层)。

2、初始化模型参数:

初始化卷积核、偏置等参数。这些参数将在训练过程中通过反向传播算法进行优化。

def train_model(model, criterion, optimizer, dataloaders, dataset_sizes, num_epochs=25,
                save_path='work/best_model.pth'):
    # 确定设备是否可用GPU,否则使用CPU
    device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
    model = model.to(device)

    # 复制模型的最佳权重
    best_model_wts = copy.deepcopy(model.state_dict())
    best_acc = 0.0

    # 用于记录训练和验证的损失和准确率的历史
    train_loss_history = []
    val_loss_history = []
    train_acc_history = []
    val_acc_history = []

    for epoch in range(num_epochs):
        print(f'Epoch {epoch}/{num_epochs - 1}')
        print('-' * 10)

        # 每一轮训练有两个阶段:训练和验证
        for phase in ['train', 'validation']:
            if phase == 'train':
                model.train()  # 设置模型为训练模式
            else:
                model.eval()  # 设置模型为评估模式

            running_loss = 0.0
            running_corrects = 0

            # 迭代数据
            for inputs, labels in dataloaders[phase]:
                inputs = inputs.to(device)
                labels = labels.to(device)

                optimizer.zero_grad()

                # 前向传递和计算梯度
                with torch.set_grad_enabled(phase == 'train'):
                    outputs = model(inputs)
                    _, preds = torch.max(outputs, 1)
                    loss = criterion(outputs, labels)

                    if phase == 'train':
                        loss.backward()
                        optimizer.step()

                running_loss += loss.item() * inputs.size(0)
                running_corrects += torch.sum(preds == labels.data)

            epoch_loss = running_loss / dataset_sizes[phase]
            epoch_acc = running_corrects.double() / dataset_sizes[phase]

            # 记录损失和准确率
            if phase == 'train':
                train_loss_history.append(epoch_loss)
                train_acc_history.append(epoch_acc.item())
            else:
                val_loss_history.append(epoch_loss)
                val_acc_history.append(epoch_acc.item())

            print(f'{phase} Loss: {epoch_loss:.4f} Acc: {epoch_acc:.4f}')

            # 深度复制模型
            if phase == 'validation' and epoch_acc > best_acc:
                best_acc = epoch_acc
                best_model_wts = copy.deepcopy(model.state_dict())

        print()

    print(f'Best val Acc: {best_acc:.4f}')
    model.load_state_dict(best_model_wts)

    # 可视化训练结果
    visualize_training_results(train_loss_history, val_loss_history, train_acc_history, val_acc_history)

    # 保存最好的模型权重到工作目录
    if not os.path.exists('work'):
        os.makedirs('work')
    torch.save(model.state_dict(), save_path)
    print(f'Model saved to {save_path}')

    return model

详解:
设备选择
检查是否可以使用GPU,如果可以,则使用GPU,否则使用CPU。
初始化
深拷贝模型的当前权重以保存最佳权重。初始化最佳准确率best_acc为0。还初始化了四个列表,用于记录每一轮训练和验证的损失(loss)和准确率(accuracy)。
训练循环
1.epoch循环:
外层循环运行num_epochs轮训练。
2.阶段循环:
每个epoch中有两个阶段:训练(‘train’)和验证(‘validation’)。
在训练阶段,模型处于训练模式(model.train()),在验证阶段,模型处于评估模式(model.eval())。
3.数据迭代:
对于每个阶段的每个批次数据,将输入和标签转移到设备(GPU或CPU),然后在训练阶段前向传播并计算梯度(loss.backward()),最后通过优化器更新模型权重(optimizer.step())。
4.损失和准确率计算:
在每个阶段结束时,计算平均损失和准确率,并将结果存储在相应的列表中。如果在验证阶段取得了更好的准确率,则深度复制当前模型权重。
打印和保存结果
打印每个epoch的损失和准确率,并在所有epoch结束后打印最佳验证准确率。加载最佳模型权重,并通过visualize_training_results函数可视化训练过程中的损失和准确率曲线。
模型保存
如果目录work不存在,则创建它,并将最佳模型权重保存到指定路径save_path。
返回值
最终返回训练好的模型。

三、训练模型

1、设置训练选项:

设置学习率、优化器(如SGD、Adam等)、损失函数(如交叉熵损失)等训练选项。

2、前向传播:

将预处理后的图像输入CNN模型,通过逐层卷积、池化、激活等操作,得到最终的输出结果。

3、计算损失函数:

将模型输出结果与真实标签进行比较,计算损失函数值。

4、反向传播:

根据损失函数值计算梯度,并通过反向传播算法更新模型参数。

5、迭代训练:

重复前向传播、计算损失函数和反向传播的过程,直到达到预设的训练轮次或满足其他停止条件(如验证集上的性能不再提升)。

# 数据处理,返回数据加载器、数据集大小和类别名称
dataloaders, dataset_sizes, class_names = file_pretreatment.data_processing()

# 加载预训练的ResNet18模型
model = models.resnet18(weights='IMAGENET1K_V1')
num_ftrs = model.fc.in_features
model.fc = nn.Linear(num_ftrs, len(class_names))

# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.001, momentum=0.9)

# 训练模型并保存
model = train_model(model, criterion, optimizer, dataloaders, dataset_sizes, num_epochs=20,save_path='work/best_model_nomask.pth')

详解
数据处理
调用file_pretreatment.data_processing()函数对数据进行预处理,并返回数据加载器(dataloaders)、数据集大小(dataset_sizes)和类别名称(class_names)。
dataloaders: 包含训练集、验证集和测试集的数据加载器,用于在训练过程中批量加载数据。
dataset_sizes: 包含训练集、验证集和测试集的样本数量。
class_names: 数据集中的类别名称,这里可能是[‘benign’, ‘malignant’, ‘normal’]。
加载预训练的ResNet18模型
加载一个预训练的ResNet18模型,使用在ImageNet上预训练的权重IMAGENET1K_V1。
num_ftrs = model.fc.in_features:获取模型最后一个全连接层的输入特征数量。
将模型的最后一层(全连接层)替换为一个新的线性层,该层的输出单元数等于类别数量(len(class_names))。这是因为原始的ResNet18模型是为ImageNet任务设计的,输出层具有1000个分类,而这里需要与当前任务的分类数相匹配。
定义损失函数和优化器
使用交叉熵损失函数CrossEntropyLoss(),这是分类任务中常用的损失函数。
定义随机梯度下降(SGD)优化器optim.SGD,设置学习率为0.001,动量为0.9。动量有助于加速梯度下降并减少震荡。
训练模型并保存
调用train_model函数训练模型,传入模型、损失函数、优化器、数据加载器和数据集大小。训练轮数(num_epochs)设置为20。
训练过程中,train_model会在每个epoch后评估模型性能,并保存验证集上表现最好的模型权重到指定路径’work/best_model_nomask.pth’。
最终,训练完成后返回的模型是验证集上表现最好的模型。

四、模型评估与优化

1、模型评估:

使用测试集对训练好的CNN模型进行评估,计算分类准确率、召回率、F1分数等指标。

2、模型优化(可选):

根据评估结果调整模型结构、参数或训练选项,以进一步提高模型性能。
可以使用fine-tuning技术在已有的CNN模型基础上进行微调,以适应新的数据集或任务。

def evaluate_model(model, dataloaders, criterion):
    # 确定设备是否可用GPU,否则使用CPU
    device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
    model = model.to(device)
    model.eval()

    running_loss = 0.0
    running_corrects = 0

    # 迭代测试数据
    for inputs, labels in dataloaders['test']:
        inputs = inputs.to(device)
        labels = labels.to(device)

        with torch.no_grad():
            outputs = model(inputs)
            _, preds = torch.max(outputs, 1)
            loss = criterion(outputs, labels)

        running_loss += loss.item() * inputs.size(0)
        running_corrects += torch.sum(preds == labels.data)

    total_loss = running_loss / len(dataloaders['test'].dataset)
    total_acc = running_corrects.double() / len(dataloaders['test'].dataset)

    print(f'Test Loss: {total_loss:.4f} Acc: {total_acc:.4f}')

详解
确定设备并设置模型模式
检查是否有可用的GPU,如果有则使用GPU,否则使用CPU。
将模型移到选定的设备(GPU或CPU)上。
设置模型为评估模式(model.eval()),以确保模型在评估时不会进行参数更新,也不会使用dropout等训练专用的技术。
迭代测试数据
遍历测试数据集,将输入数据和标签移动到选定的设备上。
使用torch.no_grad()来禁用梯度计算,因为在评估阶段不需要反向传播,这样可以节省内存和计算资源。
前向传播:将输入数据传递给模型,得到输出(outputs)。
通过torch.max(outputs, 1)找到每个输出中最大值的索引,即模型预测的类别(preds)。
计算损失:使用传入的损失函数计算当前批次的损失。
累积损失和正确预测数量
累积当前批次的损失值,并乘以输入的样本数量以保持与数据集规模一致。
计算当前批次中正确预测的样本数量,并将其累积到running_corrects中。
计算测试集的总损失和准确率
total_loss: 通过将累积的损失除以测试集的总样本数,得到平均损失。
total_acc: 将累积的正确预测数量除以测试集的总样本数,得到准确率。

五、用训练好的模型经行预测

def predict_and_visualize(model_path='work/best_model_nomask.pth', selected_images_path='work/Selected_Images',
                          save_path='work/predictions.png'):
    # 加载预训练的模型
    model = models.resnet18(weights=None)
    num_ftrs = model.fc.in_features
    model.fc = nn.Linear(num_ftrs, 3)  # 假设有3个类别
    model.load_state_dict(torch.load(model_path))
    model.eval()

    # 定义数据变换
    data_transform = transforms.Compose([
        transforms.Resize(256),
        transforms.CenterCrop(224),
        transforms.ToTensor(),
        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
    ])

    # 定义类别名称
    class_names = ['benign', 'malignant', 'normal']

    # 预测结果
    device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
    model = model.to(device)
    predictions = []
    correct_predictions = 0
    total_images = 0

    # 定义正则表达式来去掉圆括号及其内容
    def extract_label(filename):
        match = re.match(r'([a-zA-Z]+)', filename)
        return match.group(0) if match else 'unknown'

    # 读取选中的图片进行预测
    for image_file in os.listdir(selected_images_path):
        if image_file.endswith(('.png', '.jpg', '.jpeg')):
            image_path = os.path.join(selected_images_path, image_file)
            image = Image.open(image_path)
            image = data_transform(image).unsqueeze(0)
            image = image.to(device)

            with torch.no_grad():
                outputs = model(image)
                _, preds = torch.max(outputs, 1)
                predicted_class = class_names[preds.item()]
                # 获取实际类别(去掉圆括号及其内容)
                actual_class = extract_label(image_file.split('_')[0])
                predictions.append((image_file, actual_class, predicted_class))

                # 更新准确率统计
                if actual_class == predicted_class:
                    correct_predictions += 1
                total_images += 1

    # 计算准确率
    accuracy = (correct_predictions / total_images) * 100

    # 打印预测报告和准确率
    print(f'Prediction Report:')
    for img_name, actual, pred in predictions:
        print(f'Image: {img_name}, Actual Class: {actual}, Predicted Class: {pred}')
    print(f'Accuracy: {accuracy:.2f}%')

    # 可视化预测结果
    plt.figure(figsize=(20, 20))
    for i, (img_name, actual, pred) in enumerate(predictions):
        img_path = os.path.join(selected_images_path, img_name)
        img = Image.open(img_path)

        plt.subplot(5, 5, i + 1)
        plt.imshow(img)
        plt.title(f'Actual: {actual}\nPredicted: {pred}')
        plt.axis('off')

    # 保存可视化结果
    plt.savefig(save_path)
    print(f'Predictions visualized and saved to {save_path}')

详解
加载预训练模型
加载ResNet18模型,不使用预训练的权重(weights=None)。
获取模型最后一层(全连接层)的输入特征数,并将其替换为一个新的线性层,输出维度为3,表示模型有3个分类。
加载指定路径的模型权重model.load_state_dict(torch.load(model_path))。
将模型设置为评估模式(model.eval()),以确保模型在预测时不会进行参数更新。
定义数据变换
定义一系列图像变换步骤,用于在将图像输入模型之前进行预处理:
Resize(256): 将图像缩放至256像素大小。
CenterCrop(224): 将缩放后的图像中心裁剪为224x224像素。
ToTensor(): 将图像转换为张量。
Normalize(…): 使用ImageNet数据集的均值和标准差对图像进行归一化。
设置类别名称
定义类别名称列表,用于在预测结果中显示
预测和统计
设置设备(GPU或CPU),并将模型移动到该设备。
初始化存储预测结果、正确预测数量和总图像数量的变量。
辅助函数
extract_label函数使用正则表达式从文件名中提取实际类别标签,这里假设文件名的格式可以通过这种方式得到实际标签。
迭代图像文件进行预测
遍历选定的图像文件夹,将每个图像文件加载并应用数据变换,然后将其转移到设备上。
在不计算梯度的情况下,使用模型对图像进行预测,获取预测类别索引,并转换为类别名称。
提取实际类别标签,并将文件名、实际类别和预测类别存储到predictions列表中。
比较实际类别与预测类别,如果相同,则更新正确预测数量,并增加总图像数量。
可视化和保存预测结果
创建一个大的图形窗口,用于展示每张图像的实际和预测类别。
遍历预测结果,将图像和标题(包括实际和预测类别)显示在图形中。
将可视化结果保存到指定路径,并打印保存的消息。

  • 17
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值