tensorflow2.0快速入门

TensorFlow 最初于 2011 年以 Google 的内部封闭源代码项目 DisBelief 诞生,2015 年 11 月 9 日根据 Apache 2.0 开源许可证发布。2019 年推出了 TensorFlow 2.0,其对 API 进行了简化和优化,删除或改变了部分 1.x 的 API,并引入了多项新功能和特性

这篇文章将使用通俗易懂的语言介绍TensorFlow 2.0的使用方法,以及深度学习的具体流程

由于TensorFlow与PyTorch同为深度学习框架,之前的PyTorch文章讲过的与之相同的内容将不再赘述

请结合 TensorFlow中文文档 进行学习

张量和常用函数

Tensor:

深度学习的一种数据类型,又称为 张量 ,实质为多维矩阵

  • 可以进行GPU计算的矩阵
  • 包装了反向神经网络所需参数的数据类型

张量类型:

  • 0阶:标量 scalar
    • s = 1 2 3
  • 1阶:向量 vector
    • v = [1, 2, 3]
  • 2阶:矩阵 matrix
    • m = [[1, 2, 3], [1, 2, 3], [1, 2, 3]]
  • n阶:张量 tensor
    • t = [[[ ... ]]] (有多少个中括号就是多少阶张量)

上述的 0、1、2 阶都是张量

在TensorFlow中,输入的数据需为2D,即[[...]]

数据类型

无论在PyTorch还是TensorFlow中,输入数据最好为 float 类型

  • tf.int:tf.int32
  • tf.float:tf.float32、tf.float64
  • tf.bool
  • tf.string

常用函数

导入TensorFlow:

1
import tensorflow as tf

创建张量

1
tf.constant(data, dtype=数据类型)

numpy转Tensor:

1
tf.convert_to_tensor(data, dtype=数据类型)

Tensor转numpy:

1
数据.numpy()

特殊张量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 创建全0张量
tf.zeros(维度)

# 创建全1张量
tf.ones(维度)

# 创建指定填充的张量
tf.fill(维度, 指定值)

# 创建符合正态分布的张量(默认均值为0,标准差为1)
tf.random.normal(维度, mean=均值, stddev=标准差, seed=随机数种子)

# 创建符合正态分布的张量(被截断为:均值 ±2 个标准差 的范围)
tf.random.truncated_normal(维度, mean=均值, stddev=标准差, seed=随机数种子)

# 创建均匀分布的张量
tf.random.uniform(维度, minval=最小值, maxval=最大值, seed=随机数种子)
# 相同的随机数种子可以让不同的数按照同样的生成规律生成数据

其中的维度一般为:

  • 一维:[数量] or (数量)
  • 二维:[行,列] or (行,列)
  • 三维:[...,...,...] or (...,...,...)
  • ...

例如:

1
2
3
4
5
tf.zeros([2, 3])

tf.fill((2, 3), 3)

tf.random.truncated_normal([4, 3], stddev=0.1, seed=1)

其他函数

下列函数在手动编写网络时会用到,若要直接使用 Keras 编写可以选择性查看

强制类型转换

1
tf.cast(张量, dtype=类型)

标记为可训练

1
tf.Variable(data)

被标记为可训练的数据,可以在反向传播中记录梯度信息

在手动编写网络,不使用Keras时,其为必须函数

张量运算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 取最小值
tf.reduce_min(张量, axis=轴)

# 取最大值
tf.reduce_max(张量, axis=轴)

# 计算均值
tf.reduce_mean(张量, axis=轴)

# 计算总和
tf.reduce_sum(张量, axis=轴)
# 轴的不同取值方法在之前的 快速入门数据分析 中讲到过,不再赘述

# 张量相加
tf.add(张量1, 张量2)

# 张量相减
tf.substract(张量1, 张量2)

# 张量相乘
tf.multiply(张量1, 张量2)

# 张量相除
tf.divide(张量1, 张量2)

# 求平方
tf.square(张量)

# 求n次方
tf.pow(张量, n)

# 求开方
tf.sqrt(张量)

# 矩阵乘法
tf.matmul(矩阵1, 矩阵2) # 遵守矩阵乘法法则
<=> 矩阵1 @ 矩阵2

在上述函数中,涉及两个不同张量的函数,需要两个张量维度相同才可以计算

手动编写网络相关

特征与标签配对:

1
2
tf.data.Dataset.from_tensor_slices((特征, 标签))
# 其后可加 .batch(数量) 规定每一组数据的数量

返回值为:含有(特征,标签)的数据对的列表

1
2
tf.data.Dataset.from_tensor_slices((x_train, y_train)).batch(32)
tf.data.Dataset.from_tensor_slices((x_test, y_test)).batch(32)

求梯度:

1
2
3
with tf.GradientTape() as tape:  # 一个上下文管理器
...
grad = tape.gradient(函数, 求导对象)

案例代码:

1
2
3
4
5
with tf.GradientTape as tape:
w = tf.Variable(tf.constant(3.0))
loss = tf.pow(w, 2)

grad = tape.gradient(loss, w)

上述 grad 的值为 loss 在 w=3 时的一阶导数值

枚举:

1
enumerate(列表)  # 输出值为 索引 元素 的组合

案例:

1
2
3
4
5
6
7
8
for i, element in enumerate(列表):
print(i, element)

# 输出为
0 val0
1 val1
2 val2
...

独热码:

1
2
tf.one_hot(标签, depth=几分类)
# 返回值为布尔矩阵

案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
labels = [1, 0, 2]
class_num = 3

tf.one_hot(labels, depth=class_num)

'''
输出值:
0 1 2 label
([0, 1, 0], --> 1
[1, 0, 0], --> 0
[0, 0, 1]) --> 2

'''

概率分布转换:

1
tf.nn.softmax(数据)  # 将数值转换为概率

softmax 在我们机器学习入门教程中介绍过原理,这里不再赘述

自更新:

1
assign_sub(输入)  # 自减操作

其中的输入必须被定义为可训练

案例:

1
2
3
w = tf.Variable(4)
w.assign_sum(1)
print(w) # 输出为 4-1=3

常用在参数更新公式中:

1
2
3
4
grad = tape.gradient(loss, w1)

# w1的更新表达式 w1 = w1-lr*loss
w1.assign_sub(lr * grad)

返回最大值的索引:

1
tf.argmax(张量, axis=轴)  # 最小值为 tf.argmin()

手动编写的网络我们不做赘述,这里提供一个手动编写鸢尾花识别的网络:

案例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import os
import tensorflow as tf
import numpy as np
from sklearn.datasets import load_iris
from sklearn import datasets
import pandas as pd
from matplotlib import pyplot as plt
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'


data = datasets.load_iris().data
target = datasets.load_iris().target

# 打乱数据和标签
np.random.seed(0)
np.random.shuffle(data)
np.random.seed(0)
np.random.shuffle(target)

# 分出训练集和测试集
x_train = data[: -30]
y_train = target[:-30]
x_test = data[-30:]
y_test = target[-30:]

# 转数据类型
x_train = tf.cast(x_train, tf.float32)
x_test = tf.cast(x_test, tf.float32)

# 将数据一一对应并分组
train_db = tf.data.Dataset.from_tensor_slices((x_train, y_train)).batch(32)
test_db = tf.data.Dataset.from_tensor_slices((x_test, y_test)).batch(32)

# 构建NN模型
w1 = tf.Variable(tf.random.truncated_normal([4, 3], stddev=0.1, seed=1))
b1 = tf.Variable(tf.random.truncated_normal([3], stddev=0.1, seed=1))

# 设置参数
lr = 0.1
loss_data = []
acc_data = []
epoch = 500
loss_all = 0

for epoch in range(epoch):
# 开始训练
print('-'*30 + '开始训练' + '-'*30)
for step, (x_train, y_train) in enumerate(train_db):
with tf.GradientTape() as tape:
# 构建训练模型 y=w1*x+b1
y = tf.matmul(x_train, w1) + b1
# 将输出转为概率分布
y = tf.nn.softmax(y)
# 将标签转为独热码
y_ = tf.one_hot(y_train, depth=3)
# 构建损失函数 loss = (y_-y)^2
loss = tf.reduce_mean(tf.square(y_-y))
loss_all += loss
# 对损失函数求偏导数w1和b1
grad = tape.gradient(loss, [w1, b1])
# w1的更新表达式 w1 = w1-lr*loss
w1.assign_sub(lr * grad[0])
# b1的更新表达式 b1 = b1-lr*loss
b1.assign_sub(lr * grad[1])
average_loss = loss_all / len(train_db)
loss_data.append(average_loss)
loss_all = 0
print(f'Epoch:{epoch}, Loss:{average_loss}')

# 开始测试
total_correct = 0
total_num = 0
print('-'*30 + '开始测试' + '-'*30)
for x_test, y_test in test_db:
# 构建前向传播
y = tf.matmul(x_test, w1) + b1
# 将输出转为概率分布
y = tf.nn.softmax(y)
pred = tf.argmax(y, axis=1)
# 与y_test比较前,先将pred类型变为与y_test相同
pred = tf.cast(pred, y_test.dtype)
# 与y_test比较
pred = tf.equal(pred, y_test)
correct = tf.cast(pred, tf.int32)
correct = tf.reduce_sum(correct)
total_correct += int(correct)
total_num += x_test.shape[0]
acc = total_correct / total_num
print(f'Epoch:{epoch}, Acc:{acc}')
acc_data.append(acc)

# 可视化loss图
plt.title('Loss')
plt.plot(loss_data)
plt.show()

plt.title('Accuracy')
plt.plot(acc_data)
plt.show()

Keras搭建网络

使用Keras搭建网络比手动编写方便了许多

具体流程为:

  • 导入相关包
  • 加载数据集
  • 搭建网络模型
  • 定义模型训练方法
  • 定义训练参数
  • 输出参数

加载数据

  • TensorFlow有官方的数据集,具体请看官方文档
  • 使用自带方法导入CSV文件
1
2
3
4
5
6
7
8
9
10
11
import tensorflow as tf

titanic_file_path = tf.keras.utils.get_file("CSV文件路径")

titanic_csv_ds = tf.data.experimental.make_csv_dataset(
titanic_file_path,
batch_size=5, # 小批量大小
label_name='survived', # 标签列名
num_epochs=1, # 数据集遍历次数
ignore_errors=True # 忽略错误
)
  • 使用sklearn辅助进行数据导入
1
2
3
4
from sklearn import datasets
# 加载鸢尾花数据集
data = datasets.load_iris().data # 数据
target = datasets.load_iris().target # 标签
  • 使用pandas与numpy进行CSV文件导入

网络结构

使用Sequential搭建网络

1
2
model = tf.keras.models.Sequential([网络结构])
# 顺序执行Sequential中的网络

具体网络结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 拉直层
tf.keras.layers.Flatten()

# 全连接层
tf.keras.layers.Dense(神经元个数, activation='激活函数', kerned_regularizer=正则化)

'''
激活函数:
relu、softmax、sigmoid、tanh

正则化:
tf.keras.regularizers.l1() —— L1正则化
tf.keras.regularizers.l2() —— L2正则化

'''

使用类搭建网络

使用类搭建网络和PyTorch类似:继承+重写方法

导库:

1
2
from tensorflow.keras import Model
from tensorflow.keras.layers import Dense

类结构:

1
2
3
4
5
6
7
8
9
10
11
12
class MyModel(Model):
def __init__(self):
super(MyModel, self).__init__()
|...定义网络结构块...|

def call(self, x)
|...调用网络结构块,前向传播...|
return y

# 创建模型对象
模型对象 = 网络类名()
model = MyModel()

示例:

1
2
3
4
5
6
7
8
class IrisModel(Model):
def __init__(self):
super(IrisModel, self).__init__()
self.d1 = Dense(3, activation='sigmoid', kernel_regularizer=tf.keras.regularizers.l2())

def call(self, x):
y = self.d1(x)
return y

训练方法

1
model对象.compile(optimizer=优化器, loss=损失函数, metrics=['准确率'])

参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
'''
optimizer:优化器
'sgd' or tf.keras.optimizers.SGD(learning_rate=?, momentum=动量参数)

'adagrad' or tf.keras.optimizers.Adagrad(learning_rate=?)

'adadelta' or tf.keras.optimizers.Adadelta(learning_rate=?)

'adam' or tf.keras.optimizers.Adam(learning_rate=?, beta_1=0.9, beta_2=0.999)

-----------------------------------------------------------------------

loss:损失函数
'mse' or tf.keras.losses.MeanSquaredError()

'sparse_categorical_crossentropy' or tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False/True)

True:输出更加精准,重新排列公式

------------------------------------------------------------------------

metrics:准确率
'accuracy': y和y'都为数值
eg: y'=[1] y=[1]

'categorical_accuracy': y和y'都为独热码(概率分布)
eg: y'=[0, 1, 0] y=[0.2, 0.6, 0.2]

'sparse_categorical_accuracy': y'为数值,y为独热码
eg: y'=[1] y=[0.2, 0.6, 0.2]
'''

训练参数

1
2
3
4
5
6
7
model对象.fit(训练集输入特征, 训练集标签,
batch_size=?, epochs=?,
validation_data=(测试集输入特征, 测试集标签),
validation_split=从训练集划分多少比例给测试集
validation_freq=多少次epoch测试一次)

# 其中的 validation_data 和 validation_split 二选一

打印网络参数和参数设计

1
model对象.summary()

其中还可以设置输出的参数:

1
2
3
4
5
6
7
8
9
10
import os

os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'

'''
0:显示所有日志信息(默认值),包括调试信息、警告信息、错误信息等。
1:仅显示警告信息和错误信息,忽略调试信息。
2:仅显示错误信息,忽略调试信息和警告信息。
3:不显示任何日志信息,仅在发生严重错误时可能输出少量信息。
'''

数据增强

这里我们介绍图像数据的数据增强,和PyTorch中的transform差不多

使用:

1
2
3
4
5
6
7
数据增强对象 = tf.keras,preprocessing.image.ImageDataGenerator(参数)

数据增强对象.fit(数据) # 数据为四维数据(N, H, W, C)

# 改变训练参数数据

模型对象.fit(数据增强对象.flow(x_train, y_train, batch_size=?), ...其余参数...)

数据增强的参数有:

  • rescale:所有数值乘以该参数
  • rotation_range:随机度数旋转
  • width_shift_range:随机宽度偏移
  • height_shift_range:随机高度偏移
  • horizontal_flip:是否随机水平翻转
  • zoom_range:随机缩放范围(缩放[1-n, 1+n]倍)

断点续训

在一些情况下,不得不终止训练,下一次仍从上次结束的位置继续训练

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# model.compile()

# 读取模型
# model对象.load_weights(路径文件名)
checkpoint_save_path = '保存路径/名称.ckpt'
if os.path.exists(checkpoint_save_path + '.index') # 生成ckpt文件会一起生成.index文件
print('---load the model---')
model对象.load_weights(checkpoint_save_path)

# 保存模型
保存操作对象 = tf.keras.callbacks.ModelCheckpoint(参数)

# 应用到 model.fit()
model.fit(...正常填入参数..., callbacks=[保存操作对象])

保存模型时的参数为:

  • filepath:路径名(和读取时的路径相同)
  • save_weights_only:是否只保留模型参数
  • save_best_only:是否只保留最好的模型参数

读取模型和保存模型的操作应该在 model.compile()和model.fit()之间

查看训练参数

查看训练参数一般在 model.summary() 之后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 模型对象.trainable_variables 返回模型训练参数

# 设置print输出格式
np.set_printoptions(threshold=超过多少省略显示) # 设置为np.inf表示不省略

# 打印
print(模型对象.trainable_variables)

# (可选)存入文本文件中
file = open('保存路径/文件名.txt', 'w')

for v in 模型对象.trainable_variables:
file.write(str(v.name) + '\n')
file.write(str(v.shape) + '\n')
file.write(str(v.numpy()) + '\n')
file.close()

Acc和Loss可视化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 使用history接收返回值
history对象 = model对象.fit(...)

# 提取 acc 和 loss
acc = history对象.history['sparse_categorical_accuracy'] # 训练集
loss = history对象.history['loss'] # 训练集

val_acc = history对象.history['val_sparse_categorical_accuracy'] # 测试集
val_loss = history对象.history['val_loss'] # 测试集

# 使用matplotlib画图(自己按照习惯编写)
# 示例
plt.subplot(121)
plt.plot(acc, label='Training Accuracy')
plt.plot(val_acc, label='Validation Accuracy')
plt.legend()

plt.subplot(122)
plt.plot(loss, label='Training Loss')
plt.plot(val_loss, label='Validation Loss')
plt.legend()
plt.show()

应用模型

步骤:

  • 复现模型
  • 加载参数
  • 对输入进行预处理
    • 使其满足训练时的输入,或者对图像优化提高预测概率
  • 模型预测
  • (可选)对预测结果可视化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 复现模型
model = tf.keras.models.Sequential([网络结构])

# 加载参数
checkpoint_save_path = '模型路径/名称.ckpt'
model.load_weights(checkpoint_save_path)

# 对输入进行预处理
...各种操作...

# 模型预测
model.predict(输入) # 输入格式(N, H, W)

# (可选)预测结果可视化
print(...)

可能用到的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 图片改大小
img对象.resize((H, W), Image.LANCZOS) # Image.LANCZOS防止缩放失真和模糊

# 图片转array
np.array(img对象.convert('参数'))
'''
参数:
L:灰度图
RGB
RGBA
'''

# 图片颜色反转
255 - img_arr # img_arr为转为array的图像数据

# 图片提高对比度:将低于某值的数据设置为255其余为0

# 归一化
img_arr / 255.0

# 图片改变维度
img_arr.reshape((N, H, W))

案例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import tensorflow as tf
import os
from PIL import Image
import numpy as np

# 复现模型
model = tf.keras.models.Sequential([
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(128, activation='relu'),
tf.keras.layers.Dense(10, activation='softmax')
])

# 加载参数
checkpoint_save_path = './checkpoint/mnist.ckpt'
model.load_weights(checkpoint_save_path)

# 数据预处理
for i in range(10):
img_path = f'class4/MNIST_FC/{i}.png'
img = Image.open(img_path)
img = img.resize((28, 28), Image.LANCZOS)
img_arr = np.array(img.convert('L'))

for m in range(28):
for n in range(28):
if img_arr[m][n] <= 200:
img_arr[m][n] = 255
else:
img_arr[m][n] = 0

img_arr = img_arr / 255.0
img_arr = img_arr.reshape((1, 28, 28))

# 预测
result = model.predict(img_arr)
pred = tf.argmax(result, axis=1)
print(f'输入的图片为数字:{i} 预测为:{int(pred.numpy())}')

TensorFlow描述卷积层

卷积

以二维卷积为例:Conv2D

1
tf.keras.layers.Conv2D(参数)

参数:

  • filters:卷积核个数
  • kernel_size:卷积核尺寸 单数 or (H, W)
  • strides:滑动步长 单数 or (H, W)
  • padding:填充
    • 'same':进行0填充
    • 'valid':不填充(默认)
  • activation:激活函数 'relu'、'sigmoid'、'tanh'、'softmax'等
    • 若该卷积后有BN(批标准化层)则不用写激活函数
  • input_shape:输入特征图维度(H, W, C)可省略

批标准化

具体原理之前的文章讲过,这里不再赘述

1
2
# 无需参数直接调用
tf.keras.layers.BatchNormalization()

批标准化层应位于卷积层和激活层之间

激活

这里通常使用非线性激活

1
2
tf.keras.layers.Activation('激活函数')
# relu sigmoid tanh softmax 等

池化

具体原理之前的文章讲过,这里不再赘述

1
2
tf.keras.layers.MaxPool2D(参数)  # 最大池化
tf.keras.layers.AveragePool2D(参数) # 平均池化

参数:

  • pool_size:池化核大小 单数 or (H, W)
  • strides:步长 单数 or (H, W) 默认等于pool_size
  • padding:'same' or 'valid'

Dropout

在神经网络中,将神经元按照一定概率从网络中暂时舍弃,在使用时恢复

1
tf.keras.layers.Dropout(舍弃概率)  # 范围:(0, 1)

完整网络

  • 卷积网络:

    • C:卷积

    • B:批标准化

    • A:激活

    • P:池化

    • D:Dropout

  • 全连接

    • Flatten:tf.keras.layers.Flatten()
    • Dense

示例网络:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import tensorflow as tf
import tensorflow.keras.datasets as datasets
from matplotlib import pyplot as plt
from tensorflow.keras.layers import Dense, Dropout, Flatten, Conv2D, MaxPooling2D, BatchNormalization, Activation
from tensorflow.keras import Model
import os

(x_train, y_train), (x_test, y_test) = datasets.cifar10.load_data()
x_train, x_test = x_train / 255.0, x_test / 255.0


# 搭建神经网络
class Baseline(Model):
def __init__(self):
super(Baseline, self).__init__()
self.conv2 = Conv2D(filters=6, kernel_size=5, padding='same')
self.bn = BatchNormalization()
self.activation = Activation('relu')
self.max_pool = MaxPooling2D(pool_size=(2, 2), padding='same')
self.drop = Dropout(0.2)

self.flatten = Flatten()
self.dense1 = Dense(units=128, activation='relu')
self.drop2 = Dropout(0.2)
self.dense2 = Dense(units=10, activation='softmax')

def call(self, x):
x = self.conv2(x)
x = self.bn(x)
x = self.activation(x)
x = self.max_pool(x)
x = self.drop(x)

x = self.flatten(x)
x = self.dense1(x)
x = self.drop2(x)
y = self.dense2(x)
return y


model = Baseline()

model.compile(optimizer='adam', loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False),
metrics='sparse_categorical_accuracy')

checkpoint_path = 'checkpoint/Baseline.ckpt'
if os.path.exists(checkpoint_path + '.index'):
print('-------------------Load Model---------------------')
model.load_weights(checkpoint_path)

cp_callback = tf.keras.callbacks.ModelCheckpoint(filepath=checkpoint_path,
save_weights_only=True,
save_best_only=True)

history = model.fit(x_train, y_train, batch_size=32, epochs=5, validation_data=(x_test, y_test), validation_freq=1,
callbacks=[cp_callback])

model.summary()

总结

TensorFlow2.0 是会自动使用GPU的,不需要手动开启

学习 TensorFlow 2.0 时需要多多查看其官方文档,大多数还是用到什么就去找什么

深度学习框架也需要一些深度学习和机器学习的基础,如果没有了解过,那么也可以去了解,这会对学习框架有很大的帮助

如果文章中有哪些地方错误请留言或者邮箱联系我

其他内容持续更新中,敬请期待~~~

作者

heimaolala

发布于

2025-03-24

更新于

2025-03-27

许可协议

评论