几年前,研究计算机视觉领域的数据科学家们着重于从图像中提取和理解文本,依赖当时最先进的工具:传统的OCR模型。这些模型基于字符检测和文本识别技术,比如使用CRAFT和EAST进行检测,使用如Tesseract或更复杂的神经网络进行识别。
典型的工作流程如下,被划分为明确的步骤。
1. 预处理:提高质量、矫正旋转,有时还会进行二值化处理。
2. 文本检测与识别:使用如EasyOCR或PaddleOCR这样的库来进行从头到尾的文本检测与识别,可以使得整个流程更加高效和准确。
这些工具确实给出了满意的结果,可以针对特定场景进行细微调整。然而,它们也有一些局限性,尤其是在处理低质量图像或更复杂的脚本(例如手写文字)时。
图1:一个OCR处理工作流的例子。
如图1所示的工作流程始于图像捕获,随后进行初步预处理步骤,例如去除背景以突出相关内容,并进行校正旋转以确保准确对齐。经过这些调整后,检测并分割文本区域以进行针对性处理。最后,识别文本以提取所需信息。
无需后处理的OCR:一个不完善的解决方案如图2所示,展示了OCR技术应用在护照上的一个示例,其中从各个区域提取的文字包括但不限于护照号码、姓名和出生日期。
图2: OCR技术应用在护照上的例子,突出显示了提取出来的文本部分。
传统的OCR输出结构如下:
- 检测框:定义检测到的文字区域四个角的坐标 [x,y](例如,“大不列颠及北爱尔兰联合王国”的坐标为 [[6, 2], [454, 2], [454, 26], [6, 26]])。
- 检测到的文字内容:从指定区域提取的文字内容(例如,“护照”)。
- 置信度分数:模型对检测结果的信心程度,范围从 0 到 1(例如,一个置信度分数为 0.8215)。
这种格式能精确提取重要细节,比如护照号码、姓名和日期,同时给出准确性评估。
虽然OCR可以提取文本,但仅包含文本和坐标信息的原始输出通常不足以满足业务需求。OCR缺乏理解不同文本片段之间关系的能力。例如,它无法区分提取的数据中哪些是姓名、护照号码或出生日期。这一差距凸显了需要额外的后期处理或具备上下文理解能力的模型来为文本赋予意义,并以符合特定业务需求(如身份验证或文档管理)的方式对其进行组织。没有这关键的一步,OCR的结果仍然无法满足实际需求,是一个不完整的解决方案。
对于这种情况,涉及结构化文档时,可以实施简单的NLP规则来一致地提取关键信息,如名字、日期和数字等关键字。这些规则可以利用图像中特定的位置,或者依赖某些关键词来指导信息的提取。这种OCR加上后处理的工作流程简单、计算成本低且高效,对于许多问题都适用。然而,在更复杂的情况下,它可能遇到与图像质量或文档布局变化相关的重大挑战。
重要的是要强调,如果传统的OCR流程能够满足你的业务需求,因为它简单、有效且成本低,应该首先考虑它。然而,这种方法通常在结构化布局和高质量图像的简单环境中表现最佳。对于更复杂的情况,可能需要更高级的解决方案以获得一致且准确的结果。
LLM在OCR后处理中的发展随着大型语言模型(LLM)的崛起,现在从图像中提取的文字能够更准确且灵活地被处理。OCR与LLM结合的工作流程已成为从图像中理解文字的重要一步,带来了许多好处:
- 更好的上下文理解:LLM可以理解文本在其视觉环境中的含义。
- 高级自动化:它们能够更简单地解决复杂问题,实现高级自动化。
限制条件:
- 视觉信息丢失:关键的视觉元素,例如颜色、格式或其他设计特征,可能无法被完全捕捉。
- 高计算成本:虽然大型语言模型功能强大,但使用它们会消耗大量资源,特别是在处理大规模数据集时。
- 结果依赖于上下文:结果会根据初始OCR的质量及所使用模型而有所不同。
这就是一个关于OCR和LLM成功的例子。
图3:Kumon 5年级数学工作簿文字题书封面和 OCR 结果。
如图3所示,当我们使用名为EasyOCR的传统OCR库的结果输入到Claude 3.5中时,我们在理解文本区域方面取得了很好的结果。我们提问:“书名是什么?请根据OCR结果回答:……(此处,我们提供了由EasyOCR提供的文本边界框和置信度分数)”,回答是:
根据OCR的结果,这本书似乎是名为‘KUMON 数学工作手册 问题解决’ 的书籍,特别标为‘pce 5’(这可能意味着特定的级别或系列编号)。
这展示了OCR与LLM结合使用的效果,特别是在处理类似护照这种布局明确的文档时。如果OCR输出清晰且结构化,两者结合的效果非常理想,能够实现精确提取和上下文理解。在这种情况下,这种结合不仅提高了准确性,还简化了后续处理任务,成为处理布局一致、文本区域清晰的文档的理想选择。
多模态模型:图像中的文本理解的现状和未来最近,多模态模型开始流行起来,成为了强大的补充。这些模型集成了视觉和文本信息,使它们能够同时理解和处理图像和文本,在一个统一的流程中处理。
潜力:
- 统一的提取和理解: 它们不需要将流程分成 OCR 和后期处理两个步骤了。
- 更好地关联图像和文本元素: 它们在复杂场景中更为准确,例如包含多个价格和尺寸的促销标签。
限制条件:
- 成本更高: 就像大语言模型一样,多模态模型在计算上也非常昂贵。
- 训练和适应: 它们依赖特定数据在特定问题上取得最佳效果。
我们很幸运地生活在生成式AI取得重大进展的时代。尤其在图像中的文字理解这一多模态领域,我们现在拥有一些开源模型,其性能与近期的一些闭源模型如GPT-4o和Claude 3.5-Sonnet(Claude 3.5-十四行诗)相当。
如图4所示,当前Qwen2-VL-72B在OpenVLM排行榜上以平均分74.8排名第一,展示了该模型在语言和视觉任务上的强大表现。其他模型如InternVL2和PalliGema 3B也表现出色,尤其是在这种硬件上处理简单任务时表现突出,如使用T4 GPU这类更易获取的硬件。
图4: OpenVLM排行榜图,显示了基于性能评分的顶级多模态模型表现如下。
对于更简单的任务,可以使用较小的模型,比如QwenVL 2B、InternVL 2B和PalliGema 3B模型,以在保持性能的同时降低成本并减少推理时间。同时也很重要的是评估使用量化版本的模型,因为使用这些量化版本可以在保持类似性能的同时降低成本并减少推理时间。
在下一节中,我们将探讨PalliGema2的微调过程,探讨多模态模型在信息抽取问题方面的潜力,确保在商业环境中找到既精确又可扩展性的解决方案。
PalliGema2 架构:将视觉和语言结合起来以实现高级文本分析图5: PalliGema2架构结合了一个视觉模型(SigLIP)和一个语言模型(Gemma),以处理及理解图像中的信息。
在图5中,我们可以看到PalliGema2模型的架构,该模型结合了视觉模型和语言模型。这是用多模态方法理解图像中文本的一部分。
PalliGema2 架构概览
- SigLIP 视觉模型:该组件负责处理图像的视觉信息。模型是一个经过4亿张图训练的大型对比视觉编码模型。从图中提取视觉特征,如物体、文本和空间布局。
2. 线性投影层:视觉模型处理图像后,输出特征将通过线性投影层。这一步将视觉特征转换成与语言模型兼容的格式,确保提取的视觉信息被正确编码,以便后续处理。
3. Gemma语言模型:视觉特征随后被送入Gemma,这是一个拥有20亿参数的语言模型。Gemma理解和整合视觉与文本信息。它通过使用变压器解码器来生成有意义且上下文相关的输出。例如,如果输入包括类似“摄影师在哪里休息?”这样的问题和相应的图像,Gemma能够利用视觉和文本线索来提供相关且准确的答案。
在这个架构中,视觉模型为图像提供了基础的理解,而语言模型Gemma将这些信息与其强大的语言理解能力结合,生成一个既包括图像内容又包括相关文字的回答。
这种多模态方法避免了单独使用OCR和后处理管道的必要性,提供了一个统一的系统来从图像中提取和理解文本。这使其在处理诸如包含多种元素和布局的文档或理解场景中文字等复杂案例时尤其有价值。
用于微调的数据集:Establishment-Name VQA为了对PalliGema2模型进行微调,使其能够更好地理解图像中的复杂文本内容,我们创建了一个专门用于视觉问答(VQA)任务的数据集,该任务专注于机构名称。该数据集可在Hugging Face上公开获取,网址为bernardomota/establishment-name-vqa.
此数据集包含场所图像,并配以问题和答案,以提高模型在特定视觉环境中提取和理解文本的能力。示例多样,涵盖了各种图像质量、角度和文本位置,以确保训练的稳健性。
在这一部分中,我们将介绍用于创建Establishment-Name VQA数据集的代码。该过程包括准备场所的图片,并创建与图片相关的提问,生成相应的答案,以便帮助模型从视觉输入中理解和提取场所名称。这些问题旨在帮助模型从视觉输入中理解和提取场所名称。
第一步:安装依赖首先,从Hugging Face上安装好**datasets**
库。
%pip install datasets==2.16
# 安装datasets库的2.16版本
步骤 2: 导入库
我们将需要以下库来处理图像、与Google Drive进行交互以及使用数据集等。
import os # 导入操作系统模块
from google.colab import drive # 导入google.colab的drive模块
from PIL import Image, ImageOps # 导入PIL库中的Image和ImageOps模块
import random # 导入随机数模块
import datasets # 导入数据集模块
步骤 3:生成关于图像的问题:
此功能生成一系列与识别图像中场所名称相关的一般性问题。这些问题将用作问答任务的输入。
def generate_image_questions():
"""
生成关于从图像中识别店名的问题列表。
返回:
列表:问题列表。
"""
general_questions = [
"图像中的店名是什么?",
"你能从图像中识别店名吗?",
"这幅图像指的是哪家店?",
"这张图中可见的店名是什么?",
"这张图中能看出来是哪个店的名字吗?",
"这张图中有没有显示店名?如果有,请说出来。",
"图像中显示的是哪家店的名字?",
"你能从图像中读出店名吗?",
"图像中提到了哪家店的名字?",
"在这张图中能否看到店名?如果能看到,请说出来。"
]
return general_questions
步骤 4:创建 Hugging Face 数据集: 创建 Hugging Face 数据集部分
在这里,我们定义了一个用于创建Hugging Face(一个数据集平台)数据集的函数。该函数接收图像、问题和答案的组合的列表,并将它们组织成所需的格式,以符合Hugging Face库的要求。
def create_huggingface_dataset(dataset_rows):
"""
从一个字典列表中创建Hugging Face数据集,确保数据集结构符合预期格式,并具有适当的特征类型。
参数:
dataset_rows (字典列表): 列表中的每个字典应包含'图像', '问题'和'答案'键。'图像'值应为PIL图像对象(例如,Image.open(...)),'问题'和'答案'应为字符串。
返回:
datasets.Dataset: 包含图像、问题和答案的Hugging Face数据集对象,具有指定特征。
"""
features = datasets.Features({
'image': datasets.Image(),
'question': datasets.Value('string'),
'answer': datasets.Value('string')
})
return datasets.Dataset.from_dict({
"image": [example["image"] for example in dataset_rows],
"question": [example["question"] for example in dataset_rows],
"answer": [example["answer"] for example in dataset_rows]
}, features=features)
步骤五:连接 Google 盘
要访问存储在 Google Drive 上的图像,我们需要挂载 Google Drive 并在 Hugging Face 上完成认证。
# 使用huggingface-cli登录并挂载Google驱动器
!huggingface-cli login --token $HF_TOKEN --add-to-git-credential
drive.mount('/content/drive')
第6步:加载图片
我们从指定的谷歌云端硬盘文件夹中加载所有图片,现在我们只处理有效的图片格式,比如 .png
, .jpg
, .jpeg
,通过检查文件扩展名。
图像字典 = {}
# 遍历文件夹中的所有文件
for file_name in os.listdir(IMAGE_FOLDER_PATH):
# 检查文件是否为支持的图像格式
if file_name.lower().endswith(('.png', '.jpg', '.jpeg')):
# 从文件名中移除文件扩展名
establishment_name = os.path.splitext(file_name)[0]
# 使用PIL加载图像
image_path = os.path.join(IMAGE_FOLDER_PATH, file_name)
image = Image.open(image_path)
image = ImageOps.exif_transpose(image)
# 添加到字典中
图像字典[establishment_name] = image
# 例如:显示加载的图像数量
print(f"{len(图像字典)} 张图像加载完成")
图像字典
步骤 7:来创建数据行记录
在这一步,我们为数据集创建每一行。对于每张图片,我们从生成的题目列表中随机抽取一个问题,并将相应的商家名称作为该问题的答案。
dataset_rows = []
for establishment_name, image in image_dict.items():
# 为每张图片生成问题列表
image_questions = generate_image_questions()
# 创建一行的数据字典
row = {
"image": image,
"question": random.choice(image_questions),
"answer": establishment_name
}
# 添加行到数据集列表
dataset_rows.append(row)
# 显示数据集
dataset_rows
第8步:数据集可视化
我们可以用图表展示数据集中一个具体的条目,来确认数据集的结构和内容。
# 示例:从数据集中访问特定索引,如本例中的41
idx = 41
print(custom_dataset)
print(custom_dataset[idx]['question'])
print(custom_dataset[idx]['answer'])
custom_dataset[idx]['image'];
第9步:划分数据集
现在,我们将数据集分成训练集和验证集,其中80%用来训练,20%用来验证。然后将这些部分分别命名为“train”和“validation”,以便更清晰。
# 将数据集按80%和20%的比例拆分为训练集和验证集
combined_dataset_split = custom_dataset.train_test_split(test_size=0.2)
# 将拆分后的数据集分别命名为'train'和'validation'
split_dataset = datasets.DatasetDict({
'train': combined_dataset_split['train'],
'validation': combined_dataset_split['test']
})
# 输出每个数据集的行数
print(f"“训练集”有 {len(split_dataset['train'])} 行")
print(f"“验证集”有 {len(split_dataset['validation'])} 行")
如何在 Google Colab 上使用 QLoRA 技術微調的代碼
我们在Google Colab上用了一块T4 GPU完成了微调过程。
第一步:安装依赖库首先,你需要安装所需的库文件。
!pip install -q -U datasets bitsandbytes peft git+https://github.com/huggingface/transformers.git
第二步:导入库文件:
我们导入了用于加载数据集和图像、训练模型以及处理与QLoRA相关任务所需的库。
from datasets import load_dataset, concatenate_datasets
from PIL import Image
import torch
from transformers import BitsAndBytesConfig, Trainer, TrainingArguments, PaliGemmaProcessor, AutoProcessor, PaliGemmaForConditionalGeneration
from peft import get_peft_model, LoraConfig
import os
步骤 3:认证 Hugging Face
使用访问令牌登录Hugging Face,以访问私有模型和数据集并以便进行写入操作。
!huggingface-cli login --token $HF_TOKEN --add-to-git-credential
运行此命令以使用提供的Hugging Face令牌登录并将其添加到git凭证中。
第4步:设置参数我们定义模型的参数、数据集的位置和输出文件夹。
device = "cuda" # 使用GPU训练
model_id = "google/paligemma2-3b-pt-224" # 预训练模型的ID
dataset_path = "bernardomota/establishment-name-vqa" # 自定义数据集路径
model_output = "paligemma2-qlora-st-vqa-estnamevqa-224" # 训练模型的输出路径
第五步:定义图像预处理步骤
调整并处理,将图像调整为最大宽度或高度为 640px,同时保持宽高比不变。
def 调整大小和处理(batch):
"""
调整批次中图像的大小(如果需要),并返回更新后的批次。
参数说明:
batch (dict): 包含图像和其他可能数据的字典。
返回值:
dict: 包含了调整大小后图像的更新批次。
"""
max_size = 640
images = batch['image']
# 调整每个图像的大小
resized_images = []
for img in images:
width, height = img.size
if max(width, height) > max_size:
调整比例 = max_size / max(width, height)
new_width = int(width * 调整比例)
new_height = int(height * 调整比例)
img = img.resize((new_width, new_height), Image.LANCZOS)
resized_images.append(img)
batch['image'] = resized_images
return batch
第六步:开始加载并预处理数据集
我们先加载自定义的数据集,将其划分成训练集和验证集,并应用图像预处理。
# 加载数据集
ds_custom = load_dataset(dataset_path, trust_remote_code=True)
# 获取训练数据集
train_ds_custom = ds_custom["train"]
# 获取验证数据集
val_ds_custom = ds_custom["validation"]
# 调整大小并处理训练数据集
train_ds_custom = train_ds_custom.map(resize_and_process, batched=True)
# 调整大小并处理验证数据集
val_ds_custom = val_ds_custom.map(resize_and_process, batched=True)
# 打印训练数据集
print("训练数据集:", train_ds_custom)
# 打印验证数据集
print("验证数据集:", val_ds_custom)
我们还使用了一个名为ST-VQA的公开VQA数据集,该数据集可以从Hugging Face上的‘vikhyatk/st-vqa’下载。由于我们的自定义数据集较小,这个数据集将有助于我们的微调过程。通过加入ST-VQA,我们希望提升模型的性能和泛化能力。为了方便处理,我们只用了数据集的10%。
# 处理'qas'并返回扩展行的函数
def process_qas(examples):
# 扁平化qas列表,高效地提取问题、答案和图片
questions = [qa['question'] for qas_list in examples['qas'] for qa in qas_list]
answers = [qa['answers'][-1] for qas_list in examples['qas'] for qa in qas_list]
images = [image for image, qas_list in zip(examples['image'], examples['qas']) for _ in qas_list]
return {'question': questions, 'image': images, 'answer': answers}
ds_stvqa = load_dataset('vikhyatk/st-vqa')['train']
ds_stvqa_sample = ds_stvqa.train_test_split(test_size=0.9)['train']
ds_stvqa_formatted = ds_stvqa_sample.map(process_qas, batched=True, remove_columns=['qas'])
# 将数据集分为90%用于训练,10%用于验证
ds_stvqa_formatted_split = ds_stvqa_formatted.train_test_split(test_size=0.1)
train_ds_stvqa = ds_stvqa_formatted_split['train']
val_ds_stvqa = ds_stvqa_formatted_split['test']
train_ds_stvqa = train_ds_stvqa.map(resize_and_process, batched=True)
val_ds_stvqa = val_ds_stvqa.map(resize_and_process, batched=True)
print(train_ds_stvqa)
print(val_ds_stvqa)
train_ds = concatenate_datasets([train_ds_custom, train_ds_stvqa])
val_ds = concatenate_datasets([val_ds_custom, val_ds_stvqa])
print(train_ds)
print(val_ds)
idx = -1
print(train_ds[idx])
train_ds[idx]['image'],
步骤七:启动处理器:
我们将初始化 PaliGemmaProcessor 用于分词和图像预处理的组件,这是模型所用的。
processor = PaliGemmaProcessor.from_pretrained(model_id) # 从预训练模型中加载PaliGemmaProcessor
第8步:配置比特和字节(4位量化处理)
为了高效地加载使用量化权重的模型,我们使用BitsAndBytes来进行4位量化:
# 初始化bnb_config配置
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # 加载4位精度
bnb_4bit_quant_type="nf4", # 4位精度量化类型
bnb_4bit_compute_type=torch.bfloat16 # 4位精度计算类型
)
第9步:配置 LoRA 以进行调整
我们将LoRA(低秩适应)配置为微调训练模型中的特定模块,从而使整个过程更加节省内存。
lora_config = LoraConfig( # LoraConfig配置
r=8, # 参数r设置为8
target_modules=["q_proj", "o_proj", "k_proj", "v_proj", "gate_proj", "up_proj", "down_proj"], # 目标模块列表
task_type="CAUSAL_LM", # 任务类型为CAUSAL_LM,此术语在中文软件开发中可能需要额外解释
)
步骤 10:加载预训练模型并应用 QLoRA
我们加载 PaliGemmaForConditionalGeneration 模型,应用量化配置参数,并将其集成 QLoRA。
# 初始化模型
model = PaliGemmaForConditionalGeneration.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map={"": 0},
torch_dtype=torch.bfloat16
)
# 获取增强模型
model = get_peft_model(model, lora_config)
# 打印可训练参数
model.print_trainable_parameters()
步骤 11:设置训练参数
我们为微调过程定义了TrainingArguments,包括批量大小、学习率、epoch 数等:
args = TrainingArguments(
num_train_epochs=1,
remove_unused_columns=False,
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
warmup_steps=2,
learning_rate=2e-5,
weight_decay=1e-6,
adam_beta2=0.999,
logging_steps=100,
optim="paged_adamw_8bit", # 优化器选择
save_strategy="steps",
save_steps=1000,
save_total_limit=1,
bf16=True,
output_dir="输出目录",
report_to=["报告给"],
dataloader_pin_memory="数据加载器固定内存"
)
步骤十二:开始创建一个合并函数
我们定义了一个collate_fn函数来准备训练用的数据批次。这个函数保证模型能够获得正确的输入格式,以便更好地进行训练。
def collate_fn(examples):
# 将问题和图像合并成文本
texts = ["answer " + example["question"] for example in examples]
# 获取标签
labels = [example['answer'] for example in examples]
# 将图像转换为RGB格式
images = [example["image"].convert("RGB") for example in examples]
# 将输入(问题和图像)进行处理
tokens = processor(
text=texts, images=images, suffix=labels,
return_tensors="pt", padding="longest",
input_data_format="channels_last"
)
# 将tokens转换到指定的数据类型和设备上
tokens = tokens.to(DTYPE).to(device)
return tokens
第十三步:训练这个模型
我们初始化Trainer类,指定模型、训练参数设置以及训练数据集。接着,我们调用train()
方法进行微调。
trainer = Trainer(
model=model, # 模型
train_dataset=train_ds, # 训练数据集
eval_dataset=val_ds, # 评估数据集
data_collator=collate_fn, # 数据整理函数
args=args # 参数
)
# 开始训练模型
trainer.train()
这标志着使用 QLoRA 对 PaliGemma2 模型进行微调的完成。该模型经过训练,可以识别场景中的文本,比如图片中的店铺名字,它以高效利用内存的方式,结合视觉和文本信息,通过量化和LoRA技术实现高效处理。
步骤 14:测试微调后的模型最后一步,确认一下经过微调的模型是否符合预期。
从transformers.image_utils导入加载图像的函数load_image
model_id = "bernardomota/paligemma2-qlora-st-vqa-estnamevqa-224"
model = PaliGemmaForConditionalGeneration.from_pretrained(model_id) # 模型ID为“bernardomota/paligemma2-qlora-st-vqa-estnamevqa-224”
processor = AutoProcessor.from_pretrained("google/paligemma2-3b-pt-224") # 使用“google/paligemma2-3b-pt-224”预训练的处理器
url = "https://itajaishopping.com.br/wp-content/uploads/2023/02/burgerking-itajai-shopping.jpg"
image = load_image(url) # 加载图片
# 对于预训练模型,将提示留空
prompt = "这个地方叫什么名字?"
model_inputs = processor(text=prompt, images=图像, return_tensors="pt").to(torch.bfloat16).to(model.device)
输入长度 = model_inputs["input_ids"].shape[-1]
# 在torch推理模式下
with torch.inference_mode():
generation = model.generate(**model_inputs, max_new_tokens=100, do_sample=False)
# 取生成文本中从输入长度之后的部分
generation = generation[0][输入长度:]
decoded = processor.decode(generation, skip_special_tokens=True) # 跳过特殊字符
# 打印解码结果
print(decoded)
答案是:Burger King。看来有效了!完整代码可以查看 here
总结:选择哪一种工作流比较好?选择传统光学字符识别、OCR结合大语言模型或多模态模型取决于手头的业务需求。
1. 传统的 OCR 技术: 最适合计算成本成为主要限制因素的简单问题。它为简单的文本提取工作提供了快速且经济实惠的解决方案。
2. OCR + LLM: 适用于需要更高文本理解能力的中级问题。这种方法在基本OCR功能的基础上增加了上下文理解能力,提供了更深入的分析。
3. 多模态模型: 在需要同时理解文本和图像的复杂场景中,它们是最佳选择。这些模型在理解视觉元素和文本信息之间的语义关系方面表现出色,虽然这样会带来较高的计算成本。
PalliGema2 模型展示了多模态架构的力量,提供了一个针对需要精确理解图像中文本信息的任务的定制化解决方案。通过使用诸如 establishment-name VQA 等自定义数据集对 PalliGema2 进行微调,可以实现高度特定任务中的最先进的性能,以持续且可扩展的方式弥合文本理解和视觉理解之间的差距。
共同学习,写下你的评论
评论加载中...
作者其他优质文章