使用pytorch构建GAN网络并实现FID评估

上一篇文章介绍了GAN的详细理论,只要掌握了GAN,对于后面各种GAN的变形都变得很简单,基础打好了,盖大楼自然就容易了。既然有了理论,实践也是必不可少的,这篇文章将使用mnist数据集来实现简单的GAN网络,并附带使用FID来评估生成质量。

1. FID评估方法

1.1 计算方法

Fréchet Inception Distance (FID),是一种用于评估生成模型生成图像质量的指标,通常用于比较生成图像与真实图像之间的相似度,FID的数值越低表示生成的图像质量越好。具体来源可自行百度一下,这里不在介绍。FID是通过计算两组图像的均值,方差的距离,从而计算两组图像分布的相似读。直接看公式:
F I D ( r e a l , g e n ) = ∣ ∣ μ r e a l − μ g e n ∣ ∣ 2 2 + T r ( C r e a l + C g e n − 2 ( C r e a l C g e n ) 1 / 2 ) FID(real,gen) = ||\mu_{real}-\mu_{gen}||_2^2 + Tr(C_{real} + C_{gen} - 2(C_{real}C_{gen})^{1/2}) FID(real,gen)=∣∣μrealμgen22+Tr(Creal+Cgen2(CrealCgen)1/2)
其中 μ r e a l , μ g e n \mu_{real},\mu_{gen} μreal,μgen是real数据和gen数据分布的均值, C r e a l , C g e n C_{real},C_{gen} Creal,Cgen表示real和gen各自特征向量的各自的协方差;Tr表示矩阵的迹 T r ( A ) = ∑ i = 1 n A i i Tr(A)=\sum_{i=1}^nA_{ii} Tr(A)=i=1nAii(方阵对角线元素之和)。
这里需要注意到是,一般情况real数据和gen数据是经过inception V3模型提取图像特征后的结果,并非真实输入图片。

1.2 代码实现

虽然有些库里面集成了FID函数,为了更好理解,我们手动来实现这个代码。
主要分为三个部分来计算:

  • inception V3 特征提取
  • 均值计算、协方差计算
  • FID计算

具体我们来看一下完整代码实现。

import torch
import torchvision.models as models
import numpy as np
from scipy import linalg

"""
FID 测试一般3000~5000张图片,
FID小于50:生成质量较好,可以认为生成的图像与真实图像相似度较高。
FID在50到100之间:生成质量一般,生成的图像与真实图像相似度一般。
FID大于100:生成质量较差,生成的图像与真实图像相似度较低。
"""


# 加载预训练inception v3模型, 并移除top层,第一次运行会下载模型到cache里面
def load_inception():
    model = models.inception_v3(weights='IMAGENET1K_V1')
    model.eval()
    # 将fc用Identity()代替,即去掉fc层
    model.fc = torch.nn.Identity()
    return model


# inception v3 特征提取
def extract_features(images, model):
    # images = images / 255.0
    with torch.no_grad():
        feat = model(images)
    return feat.numpy()


# FID计算
def cal_fid(images1, images2):
    """
    images1, images2: nchw 归一化,且维度resize到[N,3,299,299]
    """
    model = load_inception()
	
	#1. inception v3 特征
    feats1 = extract_features(images1, model)
    feats2 = extract_features(images2, model)
	
	#2. 均值协方差
    feat1_mean, feat1_cov = np.mean(feats1, axis=0), np.cov(feats1, rowvar=False)
    feat2_mean, feat2_cov = np.mean(feats2, axis=0), np.cov(feats2, rowvar=False)

    #3. Fréchet距离
    sqrt_trace_cov = linalg.sqrtm(feat1_cov @ feat2_cov)
    fid = np.sum((feat1_mean - feat2_mean) ** 2) + np.trace(feat1_cov + feat2_cov - 2 * sqrt_trace_cov)
    return fid.real


if __name__ == '__main__':
    f = cal_fid(torch.rand(1000, 3, 299, 299), torch.rand(1000, 3, 299, 299))
    print(f)


2. 构建GAN网络

参考:
https://github.com/growvv/GAN-Pytorch/blob/main/README.md

2.1 使用全连接构建一个最简单的GAN网络

2.1.1 网络结构

import torch
import torch.nn as nn
from torchinfo import summary


class Discriminator(nn.Module):
    def __init__(self, in_features):
        super().__init__()
        self.disc = nn.Sequential(
            nn.Linear(in_features, 256),  # 784 -> 256
            nn.LeakyReLU(0.2),  #
            nn.Linear(256, 256), # 256 -> 256
            nn.LeakyReLU(0.2),
            nn.Linear(256, 1),  # 255 -> 1
            nn.Sigmoid(),   # 将实数映射到[0,1]区间
        )

    def forward(self, x):
        return self.disc(x)


class Generator(nn.Module):
    def __init__(self, z_dim, image_dim):
        super().__init__()
        self.gen = nn.Sequential(
            nn.Linear(z_dim, 256),   # 64 升至 256维
            nn.ReLU(True),
            nn.Linear(256, 256),   # 256 -> 256
            nn.ReLU(True),
            nn.Linear(256, image_dim), # 256 -> 784
            nn.Tanh(),  # Tanh使得生成数据范围在[-1, 1],因为真实数据经过transforms后也是在这个区间
        )

    def forward(self, x):
        return self.gen(x)


if __name__ == "__main__":
    gnet = Generator(64, 784)
    dnet = Discriminator(784)

    summary(gnet, input_data=[torch.randn(10, 64)])
    summary(dnet, input_data=[torch.randn(10, 784)])

网络结构运行以上代码,可以查看模型结构:

在这里插入图片描述

2.1.2 训练代码

以下是训练代码,直接可以运行

import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.datasets as datasets
from torch.utils.data import DataLoader
import torchvision.transforms as transforms
from torch.utils.tensorboard import SummaryWriter
from simplegan import Generator, Discriminator

# 超参数
device = "cuda" if torch.cuda.is_available() else "cpu"
lr = 3e-4
z_dim = 64
image_dim = 28 * 28 * 1
batch_size = 32
num_epochs = 100

Disc = Discriminator(image_dim).to(device)
Gen = Generator(z_dim, image_dim).to(device)
opt_disc = optim.Adam(Disc.parameters(), lr=lr)
opt_gen = optim.Adam(Gen.parameters(), lr=lr)
criterion = nn.BCELoss()  # 单目标二分类交叉熵函数

transforms = transforms.Compose(
    [transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)),]
)
dataset = datasets.MNIST(root="dataset/", transform=transforms, download=True)
loader = DataLoader(dataset=dataset, batch_size=batch_size, shuffle=True)

fixed_noise = torch.randn((batch_size, z_dim)).to(device)
write_fake = SummaryWriter(f'logs/fake')
write_real = SummaryWriter(f'logs/real')
step = 0

for epoch in range(num_epochs):
    for batch_idx, (real, _) in enumerate(loader):
        real = real.view(-1, 784).to(device)
        batch_size = real.shape[0]
        ## D: 目标:真的判断为真,假的判断为假
        ## 训练Discriminator: max log(D(x)) + log(1-D(G(z)))
        disc_real = Disc(real)#.view(-1)  # 将真实图片放入到判别器中
        lossD_real = criterion(disc_real, torch.ones_like(disc_real))  # 真的判断为真

        noise = torch.randn(batch_size, z_dim).to(device)
        fake = Gen(noise)  # 将随机噪声放入到生成器中
        disc_fake = Disc(fake).view(-1)  # 识别器判断真假
        lossD_fake = criterion(disc_fake, torch.zeros_like(disc_fake))  # 假的应该判断为假
        lossD = (lossD_real + lossD_fake) / 2  # loss包括判真损失和判假损失

        Disc.zero_grad()   # 在反向传播前,先将梯度归0
        lossD.backward(retain_graph=True)  # 将误差反向传播
        opt_disc.step()   # 更新参数

        # G: 目标:生成的越真越好
        ## 训练生成器: min log(1-D(G(z))) <-> max log(D(G(z)))
        output = Disc(fake).view(-1)   # 生成的放入识别器
        lossG = criterion(output, torch.ones_like(output))  # 与“真的”的距离,越小越好
        Gen.zero_grad()
        lossG.backward()
        opt_gen.step()


        # 输出一些信息,便于观察
        if batch_idx == 0:

            print(
                f"Epoch [{epoch}/{num_epochs}] Batch {batch_idx}/{len(loader)}' \
                    loss D: {lossD:.4f}, loss G: {lossG:.4f}"
            )

            with torch.no_grad():
                fake = Gen(fixed_noise).reshape(-1, 1, 28, 28)
                data = real.reshape(-1, 1, 28, 28)
                img_grid_fake = torchvision.utils.make_grid(fake, normalize=True)
                img_grid_real = torchvision.utils.make_grid(data, normalize=True)

                write_fake.add_image(
                    "Mnist Fake Image", img_grid_fake, global_step=step
                )
                write_real.add_image(
                    "Mnist Real Image", img_grid_real, global_step=step
                )
                step += 1

使用 tensorboard --logdir=./log/fake 查看生成的质量, 这个是41个epoch的结果,想要质量更好一点,可以继续训练。
在这里插入图片描述

2.2 DCGAN网络

DCGAN只是把全连接替换成全卷积的结构,思路完全一样,没什么变换

2.2.1 DCGAN网络结构

"""
Discriminator and Generator implementation from DCGAN paper
"""

import torch
import torch.nn as nn
from torchinfo import summary


class Discriminator(nn.Module):
    def __init__(self, channels_img, features_d):
        super().__init__()
        self.disc = nn.Sequential(
            self._block(channels_img, features_d, kernel_size=4, stride=2, padding=1),
            self._block(features_d, features_d * 2, 4, 2, 1),
            self._block(features_d * 2, features_d * 4, 4, 2, 1),
            self._block(features_d * 4, features_d * 8, 4, 2, 1),
            nn.Conv2d(features_d * 8, 1, kernel_size=4, stride=2, padding=0),
            nn.Sigmoid(),
        )

    def _block(self, in_channels, out_channels, kernel_size, stride, padding):
        return nn.Sequential(
            nn.Conv2d(
                in_channels,
                out_channels,
                kernel_size,
                stride,
                padding,
                bias=False
            ),
            nn.LeakyReLU(0.2),
        )

    def forward(self, x):
        return self.disc(x)


class Generator(nn.Module):
    def __init__(self, channels_noise, channels_img, features_g):
        super().__init__()
        self.gen = nn.Sequential(
            self._block(channels_noise, features_g * 16, 4, 1, 0),
            self._block(features_g * 16, features_g * 8, 4, 2, 1),
            self._block(features_g * 8, features_g * 4, 4, 2, 1),
            self._block(features_g * 4, features_g * 2, 4, 2, 1),
            nn.ConvTranspose2d(features_g * 2, channels_img, 4, 2, 1),
            nn.Tanh(),
        )

    def _block(self, in_channels, out_channels, kernel_size, stride, padding):
        return nn.Sequential(
            nn.ConvTranspose2d(
                in_channels,
                out_channels,
                kernel_size,
                stride,
                padding,
                bias=False,
            ),
            nn.ReLU(),
        )

    def forward(self, x):
        return self.gen(x)


def initialize_weights(model):
    ## initilialize weight according to paper
    for m in model.modules():
        if isinstance(m, (nn.Conv2d, nn.ConvTranspose2d,)):
            nn.init.normal_(m.weight.data, 0.0, 0.02)


def test():
    N, in_channels, H, W = 8, 1, 64, 64
    noise_dim = 100
    x = torch.randn(N, in_channels, H, W)
    disc = Discriminator(in_channels, 8)
    initialize_weights(disc)
    assert disc(x).shape == (N, 1, 1, 1), "Discriminator test failed"
    gen = Generator(noise_dim, in_channels, 8)
    initialize_weights(gen)
    z = torch.randn(N, noise_dim, 1, 1)
    assert gen(z).shape == (N, in_channels, H, W), "Generator test failed"


if __name__ == "__main__":
    gnet = Generator(100, 1, 64)
    dnet = Discriminator(1, 64)

    summary(gnet, input_data=[torch.randn(10, 100, 1, 1)])
    summary(dnet, input_data=[torch.randn(10, 1, 64, 64)])

2.2.2 训练代码

import torch
import torch.nn as nn
import torch.optim as optim
from torchvision.datasets import MNIST
from torch.utils.data import DataLoader
from dcgan import Generator, Discriminator, initialize_weights
import torchvision.transforms as transforms
from torch.utils.tensorboard import SummaryWriter
import torchvision

LEARNING_RATE = 2e-4
BATCH_SIZE = 128
IMAGE_SIZE = 64
NUM_EPOCHS = 5
CHANNELS_IMG = 1
NOISE_DIM = 100
FEATURES_DISC = 64
FEATURES_GEN = 64

transforms = transforms.Compose(
    [
        transforms.Resize(IMAGE_SIZE),

        transforms.ToTensor(),
        transforms.Normalize(
            [0.5 for _ in range(CHANNELS_IMG)], [0.5 for _ in range(CHANNELS_IMG)]
        ),
    ]
)


write_fake = SummaryWriter(f'log/fake')
write_real = SummaryWriter(f'log/real')


def train(NUM_EPOCHS, gpuid):
    device = torch.device(f"cuda:{gpuid}")
    # 数据load
    # dataset = datasets.ImageFolder(root="celeb_dataset", transform=transforms)
    dataset = MNIST(root='./data', train=True, download=True, transform=transforms)
    dataloader = DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=True)

    gen = Generator(NOISE_DIM, CHANNELS_IMG, FEATURES_GEN).to(device)
    disc = Discriminator(CHANNELS_IMG, FEATURES_DISC).to(device)
    initialize_weights(gen)
    initialize_weights(disc)

    opt_gen = optim.Adam(gen.parameters(), lr=LEARNING_RATE, betas=(0.5, 0.999))
    opt_disc = optim.Adam(disc.parameters(), lr=LEARNING_RATE, betas=(0.5, 0.999))
    criterion = nn.BCELoss()

    fixed_noise = torch.randn(32, NOISE_DIM, 1, 1).to(device)
    writer_real = SummaryWriter(f"logs2/real")
    writer_fake = SummaryWriter(f"logs2/fake")
    step = 0

    gen.train()
    disc.train()

    for epoch in range(NUM_EPOCHS):
        # 不需要目标的标签,无监督
        for batch_id, (real, _) in enumerate(dataloader):
            real = real.to(device)
            noise = torch.randn(BATCH_SIZE, NOISE_DIM, 1, 1).to(device)
            fake = gen(noise)

            # Train Discriminator: max log(D(x)) + log(1 - D(G(z)))
            disc_real = disc(real).reshape(-1)
            loss_real = criterion(disc_real, torch.ones_like(disc_real))
            disc_fake = disc(fake.detach()).reshape(-1)
            loss_fake = criterion(disc_fake, torch.zeros_like(disc_fake))
            loss_disc = (loss_real + loss_fake) / 2

            disc.zero_grad()
            loss_disc.backward()
            opt_disc.step()

            # Train Generator: min log(1 - D(G(z))) <-> max log(D(G(z)), 先训练一个epoch 的D
            if epoch >= 0:
                output = disc(fake).reshape(-1)
                loss_gen = criterion(output, torch.ones_like(output))

                gen.zero_grad()
                loss_gen.backward()
                opt_gen.step()

                if batch_id % 20 == 0:
                    print(
                        f'Epoch [{epoch}/{NUM_EPOCHS}] Batch {batch_id}/{len(dataloader)} Loss D: {loss_disc}, loss G: {loss_gen}')

                    with torch.no_grad():
                        fake = gen(fixed_noise)
                        img_grid_real = torchvision.utils.make_grid(real[:32], normalize=True)
                        img_grid_fake = torchvision.utils.make_grid(fake[:32], normalize=True)
                        writer_real.add_image("Real Image", img_grid_real, global_step=step)
                        writer_fake.add_image("Fake Image", img_grid_fake, global_step=step)

            step += 1


if __name__ == "__main__":
    train(100, 0)

同样使用tensorboard --logdir=./logs2/fake 查看生成的质量,大概10个epoch的结果

在这里插入图片描述

结论

FID指标可自行测试。GAN的基本训练思路是完全按照论文来做的,包括损失函数设计完全跟论文一致。具体理论可仔细看上一篇博客。如有不足,错误请指出。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/598324.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

如何从Mac上的清空垃圾箱中恢复已删除的文件?

Mac用户几乎每天都会删除文件。当您将文档删除到 Mac 垃圾箱时&#xff0c;该文件将被剪切到 Mac 垃圾箱中&#xff0c;并且可以轻松放回原处。但是&#xff0c;在某些情况下&#xff0c;您错误地删除了文档和文件&#xff0c;并在您意识到自己犯了一个大错误之前清空了垃圾箱。…

Advanced RAG 06:生成结果的相关性低? 快用 Query Rewriting 优化技术

编者按&#xff1a;在现实生活中&#xff0c;普通用户很难编写合适的提示词&#xff08;prompt&#xff09;来指示 LLM 完成期望任务。用户提出的 queries 往往存在词汇不准确、缺乏语义信息等问题&#xff0c;导致 LLM 难以理解并生成相关的模型响应。因此&#xff0c;如何优化…

刷代码随想录有感(58):二叉树的最近公共祖先

题干&#xff1a; 代码&#xff1a; class Solution { public:TreeNode* traversal(TreeNode* root, TreeNode* p, TreeNode* q){if(root NULL)return NULL;if(root p || root q)return root;TreeNode* left traversal(root->left, p, q);TreeNode* right traversal(r…

JuiceFS v1.2-beta1,Gateway 升级,多用户场景权限管理更灵活

JuiceFS v1.2-beta1 今天正式发布。在这个版本中&#xff0c;除了进行了大量使用体验优化和 bug 修复外&#xff0c;新增三个特性&#xff1a; Gateway 功能扩展&#xff1a;新增了“身份和访问管理&#xff08;Identity and Access Management&#xff0c;IAM&#xff09;” 与…

泛型编程四:栈、堆,内存管理

文章目录 前言一、栈、堆栈&#xff08;Stack&#xff09;堆&#xff08;Heap&#xff09; 二、static生命期三、heap生命期四、new、delete的作用机制五、动态分配的内存&#xff08;in VC&#xff09;如图&#xff0c;第一列为调试模式下的复数的内存分配&#xff0c;复数有两…

电子合同:纸质合同的未来替代者?

随着科技的迅猛发展&#xff0c;电子合同作为一种新兴的合同形式&#xff0c;逐渐在各行各业中崭露头角。那么&#xff0c;电子合同是否会替代纸质合同&#xff0c;成为未来合同形式的主流呢&#xff1f;本文将就此话题展开探讨。 首先&#xff0c;我们来看电子合同的优势。电…

cookie没有携带的问题

背景&#xff1a; build-model应用在hcs迁移的时候&#xff0c;前、后端各自部署了一个新应用&#xff0c;但是调试时候发现没有cookie&#xff0c;导致鉴权失败&#xff01; 注&#xff1a; 后端通过cookie中的token做鉴权的&#xff0c;前端调用接口的时候&#xff0c;查看&…

SPD1179 电路设计---汽车电机控制设计

概述 SPD1179 是旋智针对汽车应用推出的一颗高度集成的片上系统&#xff08;SOC&#xff09; 微控制器&#xff0c;内置 32 位高性能 ARMCortex-M4F 内核&#xff0c;最高 100MHz 的软件可编程时钟频率&#xff0c; 32KB SRAM&#xff0c; 128KB 嵌入式 FLASH&#xff0c; 1KB …

04-18 周四 为LLM_inference项目配置GitHub CI过程记录

04-18 周四 为LLM_inference项目配置GitHub CI过程记录 时间版本修改人描述2024年4月18日10:30:13V0.1宋全恒新建文档 简介和相关文档 04-15 周一 GitHub仓库CI服务器配置过程文档actions-runner 是托管与GitHub上的仓库&#xff0c;下载最新的客户端程序即可。self hosted r…

多C段的美国站群服务器有什么用途?

多C段的美国站群服务器有什么用途? 多C段的美国站群服务器是一种常见的网络运营策略&#xff0c;其用途主要体现在以下几个方面&#xff1a; 多C段的美国站群服务器有什么用途? 1. 提高站点排名和流量 部署多个站点在不同的C段IP地址上&#xff0c;可以通过不同的IP地址发布…

BGP协议应用:SW1、SW2、SW3、RT1、RT2之间运行BGP协议

8.SW1、SW2、SW3、RT1、RT2之间运行BGP协议,SW1、SW2、RT1 AS号65001、RT2 AS号65002、SW3 AS号65003。 (1)SW1、SW2、SW3、RT1、RT2之间通过Loopback1建立IPv4 BGP邻居。SW1和SW2之间财务通过Loopback2建立IPv4 BGP邻居,SW1和SW2的Loopback2互通采用静态路由。 (2)SW1…

运行一个jar包

目录 传送门前言一、Window环境二、Linux环境1、第一步&#xff1a;环境配置好&#xff0c;安装好jdk2、第二步&#xff1a;打包jar包并上传到Linux服务器3、第三步&#xff1a;运行jar包 三、docker环境1、Linux下安装docker和docker compose2、Dockerfile方式一运行jar包2.1、…

优思学院|HR部门如何制定公司的精益六西格玛培训计划?

在许多企业中&#xff0c;精益六西格玛作为一种提升效率和质量的重要方法论&#xff0c;越来越受到重视。HR部门在推广和实施精益六西格玛培训计划中其实也扮演着关键角色。以下是HR部门可以采取的几个步骤&#xff0c;以有效地制定和实施这样的培训计划。 1. 需求分析 首先&…

人工智能学习+Python的优势

1.人工智能的发展阶段 1.1 强人工智能&#xff1a; 1.2 弱人工智能&#xff1a; 2.符号学习 符号学习的本质就是&#xff1a;规定好的逻辑和顺序&#xff0c;根据这个模板告诉机器接下来需要做什么&#xff0c;遵循if...then原则——>缺点&#xff1a;不能根据新的场景…

本地主机访问服务器的Redis -- 配置 ssh 端口转发

前言 在进行Java开发时&#xff0c;高度的依赖 Windows 上的开发软件 idea &#xff0c;那么我们想访问位于服务器上的 redis 怎么办呢&#xff1f;在平时我们想访问位于服务器上的程序&#xff0c;只需要开放它的端口即可&#xff0c;比如我们创建的网站&#xff0c;比如 tomc…

Go通过CRUD实现学生管理系统

虽然这个项目没有什么含金量&#xff0c;但是可以熟悉go的语法和go开发项目的一般流程 项目结构 项目实现了五个功能&#xff1a; &#xff08;1)增加一个学生 &#xff08;2&#xff09;删除一个学生 &#xff08;3&#xff09;修改一个学生的信息 &#xff08;4&#xf…

基于微信小程序的图书馆预约系统的设计与实现

个人介绍 hello hello~ &#xff0c;这里是 code袁~&#x1f496;&#x1f496; &#xff0c;欢迎大家点赞&#x1f973;&#x1f973;关注&#x1f4a5;&#x1f4a5;收藏&#x1f339;&#x1f339;&#x1f339; &#x1f981;作者简介&#xff1a;一名喜欢分享和记录学习的…

【一起深度学习吧!!!!!】24/05/03

卷积层里的多输入输出通道 1、 多输入通道&#xff1a;代码演示&#xff1a; 多输出通道&#xff1a;代码实现&#xff1a; 1、 多输入通道&#xff1a; 当输入包含多个通道时&#xff0c;需要构造一个输入通道与之相等的卷积核&#xff0c;以便进行数据互相关计算。 例如李沐…

责任链模式和观察者模式

1、责任链模式 1.1 概述 在现实生活中&#xff0c;常常会出现这样的事例&#xff1a;一个请求有多个对象可以处理&#xff0c;但每个对象的处理条件或权限不同。例如&#xff0c;公司员工请假&#xff0c;可批假的领导有部门负责人、副总经理、总经理等&#xff0c;但每个领导…

XMall-Front:基于Vue.js的XMall商城前台页面的开发实践

XMall-Front&#xff1a;基于Vue.js的XMall商城前台页面的开发实践 摘要 随着电子商务的蓬勃发展&#xff0c;用户体验逐渐成为决定电商平台成功与否的关键因素。作为XMall商城项目的一部分&#xff0c;XMall-Front是基于Vue.js的前端页面开发&#xff0c;其目标是为用户提供…
最新文章