news 2026/10/23 3:31:51

基于ONNX Runtime的PP-OCR模型部署实战:从导出到调优

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于ONNX Runtime的PP-OCR模型部署实战:从导出到调优

1. 为什么最终选 ONNX Runtime 这条路线,而不是继续扛着 Paddle 全家桶

做 OCR 部署的人,十有八九都经历过这种时刻:模型在 Paddle 里跑得飞起,一换到业务方的服务器上就各种闹脾气——环境装不上、依赖冲突、推理速度拉胯。我自己就在这个坑里爬过几轮,后来把 PP-OCR v4/v5 的模型全部导出成 ONNX,再用我一直在维护的 DeploySharp 做了一套流水线封装,终于把部署这件事从"玄学"变成了"确定性流程"。这篇就完整记录一下这条技术路线:模型导出、配置编排、推理链路、性能调优,以及我踩过的一些坑。

1.1 PP-OCR v4/v5 的部署痛点

PP-OCR 系列本身是好东西。v4 的检测、方向分类、识别三段式结构非常成熟,v5 又在网络结构和训练策略上做了不少优化,长文本、倾斜文本、低分辨率小字体的表现都比前代有明显提升。模型精度在线,真正的麻烦出在部署环节。

Paddle 的推理方案不是不能用,而是太重。一个完整的 Paddle 推理环境,要装 PaddlePaddle 框架本体,还要考虑框架版本和模型版本的绑定关系;到了纯 CPU 的虚拟化环境、ARM 设备或者某些精简系统里,安装失败、算子缺失、动态库冲突是家常便饭。业务方不会关心你模型训得多好,他们只关心"你这个东西能不能在我这台机器上稳定跑起来"。

1.2 三个候选方案的对比与取舍

在定方案之前,我把常见的几条路都过了一遍,对比维度就四个:依赖体积、跨平台能力、动态输入支持、调优手段是否丰富。

方案依赖体积跨平台动态输入调优手段
Paddle Inference大,框架级一般,x86/部分ARM支持较少,与框架绑定深
Paddle Lite中移动端较好,服务器端弱受限一般
ONNX Runtime小,单引擎Windows/Linux/macOS/ARM 全覆盖好线程、量化、TensorRT 等
DeploySharp + ONNX Runtime小好好配置化 + ORT 全量能力

最终选 ONNX Runtime,理由很直接:模型导出成 ONNX 之后,就和 Paddle 框架脱钩了,部署端只需要一个轻量的运行时;同一个 ONNX 文件,在 CPU、GPU、甚至部分 NPU 上都能跑,业务方换硬件也不用重新出模型。ONNX Runtime 对动态形状的支持也做得比较稳,PP-OCR 这种检测图尺寸不定、识别宽度不定的场景刚好需要这个能力。

1.3 DeploySharp 在这个链路里的位置

ONNX Runtime 解决了"推理引擎"的问题,但没有解决"OCR 流水线"的问题。PP-OCR 不是一个单一模型,而是检测、方向分类、识别三个模型串起来的一条链路,中间还夹着大量图像预处理和后处理。每个项目都把这些代码重新写一遍,迟早要出事。

DeploySharp 就是在这个位置发挥作用的:它是我维护的一套轻量推理编排层,负责统一管理模型加载、三段式流水线调度、前后处理参数和推理后端配置。使用方只需要准备一份 YAML 配置文件和 ONNX 模型目录,剩下的脏活累活全部由 DeploySharp 包掉。下面整个部署过程,我都按这个思路来展开。

2. 模型导出:从 Paddle 权重到 ONNX 图的完整链路

很多人卡在第一步,不是不会写推理代码,而是模型导出这一关没过好。PP-OCR 官方仓库里给的是 Paddle 格式的权重,必须先转成推理模型,再导出成 ONNX。这两步每一步都有讲究。

2.1 拿到干净的推理模型

PaddleOCR 仓库里提供了tools/export_model.py脚本。以 v5 检测模型为例,命令大概是这样的:

python tools/export_model.py \ -c configs/det/ch_PP-OCRv5_det.yml \ -o Global.pretrained_model=./pretrain/ch_PP-OCRv5_det_train \ Global.save_inference_dir=./inference/ch_PP-OCRv5_det

识别和方向分类同理,把-c换成对应的 rec/cls 配置文件即可。导出成功之后,目标目录里会生成inference.pdmodel和inference.pdiparams两个文件,前者是图结构,后者是权重。

这里有个关键建议:导出之后,先用 Paddle 官方自带的 predict 脚本,把一张测试图跑通,把检测框、识别文本、置信度这些结果全部记录下来。这份结果就是你后面所有工作的基准线——ONNX 部署的结果必须和它对得上,否则后面出了问题你根本分不清是导出阶段错了还是部署代码错了。

2.2 paddle2onnx 导出命令与动态轴的选择

推理模型目录准备好之后,用 paddle2onnx 工具做转换。

paddle2onnx \ --model_dir ./inference/ch_PP-OCRv5_det \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./onnx/ch_PP-OCRv5_det.onnx \ --opset_version 15 \ --input_shape_dict "{'x':[-1,3,-1,-1]}"

三个模型分别导一次,重点关注input_shape_dict里的动态轴设置:

  • 检测模型:输入是[N, 3, H, W],H 和 W 都设成-1,因为输入图片尺寸不固定。
  • 方向分类模型:输入固定为[1, 3, 48, 192],不需要动态轴。
  • 识别模型:输入是[N, 3, 48, W],高度固定 48,宽度建议要么设成-1做动态,要么直接固定320。

opset_version 我习惯用 15 以上。PP-OCR 的图里包含 Resize、ArgMax、Gather 这类算子,opset 太低的时候要么导出报 warning,要么转出来的图在 ONNX Runtime 里跑不通。这个值不是什么玄学,就是纯粹的经验——踩过几次低版本算子不兼容的坑之后,你就会老老实实把版本往上提。

2.3 导出后的第一件事:和 Paddle 输出对拍

导出完先别急着写部署代码,做一次"对拍"验证。写个最简单的脚本,用随机输入分别跑 Paddle 推理模型和 ONNX 模型,对比输出的 shape 和数值分布:

import onnxruntime as ort import numpy as np sess = ort.InferenceSession( "./onnx/ch_PP-OCRv5_det.onnx", providers=["CPUExecutionProvider"], ) input_name = sess.get_inputs()[0].name fake_input = np.random.rand(1, 3, 640, 640).astype("float32") outputs = sess.run(None, {input_name: fake_input}) for out in outputs: print(out.shape, out.min(), out.max())

只要输出张量的 shape 对得上,数值分布在一个正常的范围内(概率图应该在 0 到 1 附近的量级),导出这一步就算真正完成了。检测模型 v5 的图有时会带多个输出,你只需要取第一个概率图,后面那些是训练阶段用的辅助输出,部署时用不到。对拍通过之后,才进入下一步配置环节。

3. DeploySharp 的配置体系:一份 YAML 管住模型、前后处理和调度

我见过太多部署代码,模型路径、阈值、预处理参数全散落在 Python 文件里,换一个模型要改七八个地方。DeploySharp 的思路正好反过来:把一切会变的东西全部收进一个 YAML 文件,代码里只留稳定的逻辑。

3.1 三段式流水线的字段设计

一份典型的 DeploySharp 配置长这样:

pipeline: use_cls: true det: onnx_path: models/ch_PP-OCRv5_det.onnx preprocess: limit_side_len: 960 limit_type: max mean: [0.485, 0.456, 0.406] std: [0.229, 0.224, 0.225] postprocess: det_db_thresh: 0.3 det_db_box_thresh: 0.6 det_db_unclip_ratio: 1.5 cls: onnx_path: models/ch_PP-OCRv5_cls.onnx cls_image_shape: [3, 48, 192] cls_thresh: 0.9 rec: onnx_path: models/ch_PP-OCRv5_rec.onnx rec_image_shape: [3, 48, 320] rec_char_dict_path: dict/ppocrv5_dict.txt runtime: backend: cpu intra_op_num_threads: 4 inter_op_num_threads: 1 execution_mode: 0

这些字段基本上都是从 PaddleOCR 官方配置文件里直接搬过来的。比如limit_side_len: 960表示检测前把图片最长边缩放到 960,保持宽高比;det_db_thresh、det_db_box_thresh、det_db_unclip_ratio是检测后处理的三个核心阈值,分别控制二值化阈值、框置信度阈值和外扩比例。我的经验是:这些参数不要自己拍脑袋改,官方配置是经过大量数据调出来的,除非你的业务有特殊需求,否则默认值就是最优解。

3.2 推理后端、线程与内存策略的声明

runtime 段声明推理运行时的行为。backend支持cpu和cuda两种,切 GPU 时把 providers 换成CUDAExecutionProvider,同时保留 CPU 作为 fallback,避免某些机器上 CUDA 环境有问题直接崩溃。

线程数这里多说一句。intra_op_num_threads是单个算子内部并行度,inter_op_num_threads是算子之间流水线并行度。对于 PP-OCR 这种三个模型串行推理的场景,inter_op 设 1 就够了,intra_op 按物理核心数设。四核八线程的机器设 4 是合理值,设 8 反而会因为线程切换开销导致更慢——小模型尤其明显,别盲目拉满。

3.3 从配置到实例:加载流程与对外 API

DeploySharp 对外暴露的接口很薄,基本就是"读配置、建管线、跑推理"三步:

from deploySharp import OCRPipeline pipe = OCRPipeline("ocr.yaml") results = pipe.run("demo.jpg") for item in results: print(item["box"], round(item["score"], 4), item["text"])

OCRPipeline在初始化时会做这些事:加载三个 ONNX 模型并创建 ONNX Runtime session、解析预处理和后处理参数、编排三段式流水线、跑一次空输入做 warmup。warmup 这一步非常重要,ONNX Runtime 的首次推理通常会比后面慢一个量级,提前热身可以把这部分耗时从业务首帧里挤出去。

配置纳入版本管理之后,换模型、调阈值、改后端,全部变成一行 git diff 的事,不用再翻代码找参数,这是 DeploySharp 相比手写部署脚本最值的部分。

4. 核心推理链路的代码级拆解

配置只是骨架,真正的血肉是三条推理链路的实现。这里我把检测、分类、识别三段逐一拆开,讲清楚每一段输入是什么、输出是什么、中间做了什么。

4.1 文本检测的 DB 后处理

检测模型输出的是一张概率图,shape 是[1, 1, H, W],每个像素点表示"这里是不是文本"的概率。要把概率图变成文本框,需要做一长串后处理:sigmoid 归一化、阈值二值化、找连通域、多边形近似、外扩、算置信度。

import cv2 import numpy as np import pyclipper def db_postprocess(prob_map, thresh=0.3, box_thresh=0.6, unclip_ratio=1.5): prob = 1 / (1 + np.exp(-prob_map)) mask = (prob > thresh).astype(np.uint8) contours, _ = cv2.findContours( mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) boxes = [] for cnt in contours: area = cv2.contourArea(cnt) if area < 3: continue perimeter = cv2.arcLength(cnt, True) poly = cv2.approxPolyDP(cnt, 0.002 * perimeter, True) dist = unclip_ratio * area / perimeter clipper = pyclipper.PyclipperOffset() clipper.AddPath(poly, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON) expanded = clipper.Execute(dist) if not expanded: continue rect = cv2.minAreaRect(np.array(expanded[0], dtype=np.float32)) box = cv2.boxPoints(rect) box = np.clip(box, 0, None).astype(np.int32) score = float(np.mean(prob[cnt[:, 0, 1], cnt[:, 0, 0]])) if score < box_thresh: continue boxes.append((box, score)) return boxes

这里最容易被忽略的是unclip_ratio的作用。检测模型输出的概率图在文字边缘往往是渐变的,直接按 0.3 阈值切出来的连通域会略小于真实文字区域。外扩一步能把文字边缘的零散像素包进来,避免后续裁剪时切掉笔画。这个值调太小会切字,调太大把背景框进来,1.5 是个稳妥的起点。

4.2 方向分类:一个常被忽略的精度开关

方向分类模型解决的是倒置文本问题。翻拍的证件、扫描的表格、随手拍的照片,经常出现整个页面旋转 180 度的情况。如果不矫正,识别模型面对倒着的内容,错误率会明显上升。

分类模型的输入是检测框裁剪出来的小图,输出是 2 个类别的概率:0 表示正常方向,1 表示需要旋转 180 度。部署端逻辑非常简单:

if cls_probs.argmax() == 1 and cls_probs.max() >= cls_thresh: roi = cv2.rotate(roi, cv2.ROTATE_180)

有个细节要说明:方向分类只负责判断 180 度翻转,不负责 90 度旋转。90 度的情况属于检测阶段对文本方向感知的问题,靠分类模型救不回来。

use_cls这个开关在配置里单独提出来,是因为方向分类有成本。每张检测图都要过一遍这个小模型,大概多花 3 到 8 毫秒。如果你的业务场景是纯正向的印刷体文档,比如工单、合同扫描件,可以关掉它省这笔开销。但如果是证件识别、翻拍件这类方向不可控的场景,务必开着。

4.3 文本识别的 CTC 解码与字符表对齐

识别模型是整个链路里最容易出问题的一环,也是最需要耐心对细节的一环。输入是检测框裁剪出来的文本行小图,统一缩放到高度 48、宽度动态;输出是[1, seq_len, num_classes]的 logits,其中seq_len是序列长度,num_classes是字符类别总数。

解码用 CTC greedy 策略:先对每个时间步做 argmax,合并相邻重复字符,再去掉 blank 位,最后映射到字符表:

def ctc_greedy_decode(logits, blank=0): pred_ids = logits.argmax(axis=-1) merged = [] prev = blank for idx in pred_ids: if idx != prev: merged.append(int(idx)) prev = int(idx) text_ids = [t for t in merged if t != blank] return text_ids

合并相邻重复字符这一步的顺序一定不能错:先合并重复,再去 blank。反过来做会把"好好"这种叠字错误地合并成一个"好"。原理很朴素——CTC 的 blank 只是用来分割时间步的占位符,不是真实字符,必须最后处理。

字符表对齐是重灾区。PP-OCR v4 和 v5 的 dict 文件内容不一样,v5 的字符表里多了训练时引入的特殊 token,解码后必须过滤掉。如果拿 v4 的 dict 去解 v5 模型的输出,轻则乱码,重则索引越界直接崩溃。我的经验是:导出模型时用的是哪个 dict,部署时就必须用同一个,一个字符都不能差。配置里留rec_char_dict_path就是为了让这份对应关系可追溯。

5. 性能调优:线程、量化、复用,三板斧把 CPU 延迟压下来

模型跑通只是第一步,业务方接下来一定会问:快不快?能撑多少并发?这一节我把做过的调优手段按收益从高到低排一遍。

5.1 线程、内存池和 session 复用

ONNX Runtime 的 CPU 推理性能,第一影响因素是线程配置。前面说过,intra_op 按物理核数设,inter_op 保持 1。这套配置在我的四核八线程测试机上,比默认全开快 15% 到 20%。

第二个容易踩的坑是 session 反复创建。ONNX Runtime 创建 session 要做图优化和算子选择,一次可能要几百毫秒。我见过有人把InferenceSession写在单张图片的推理函数里,结果单图延迟凭空多了 300 毫秒。正确的做法是在 DeploySharp 初始化阶段创建一次 session,之后所有推理共用。

内存方面,ONNX Runtime 默认的 arena 分配器会预留一大块内存做复用,这在内存受限的容器环境里可能触发告警。配置里可以限制 arena 大小,或者切换到显式内存策略。对于 OCR 这种单路推理场景,省内存比极致速度更实际。

5.2 INT8 量化:改动最小、收益最直接

量化是 CPU 场景下性价比最高的优化手段。ONNX Runtime 的 quantization 工具用起来非常简单:

from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "ch_PP-OCRv5_rec.onnx", "ch_PP-OCRv5_rec_int8.onnx", weight_type=QuantType.QInt8, )

我的实践结论是:识别模型做动态量化收益最明显,延迟能降一半左右,精度损失在常见印刷体场景下几乎感知不到。检测模型建议做静态量化,因为检测输出是概率图,激活值的分布对后处理阈值影响很大,动态量化容易让概率分布整体偏移,导致检测框变少。静态量化需要准备几十张校准图,流程稍复杂,但稳定性好很多。

量化完之后一定不要只看速度,必须拿一批代表性测试图重新跑一遍精度。尤其是生僻字、特殊符号多的场景,量化损失会被放大,这一步不能省。

5.3 我这边一套普通 x86 环境的实测对比

以下数据来自一台四核八线程的 x86 笔记本 CPU 加一张入门级独立显卡,输入图片最长边 960 像素,包含 3 到 5 段文本。不是严谨 benchmark,但能反映量级差异。

配置det 耗时cls 耗时rec 耗时单图总耗时
CPU FP32,4 线程88ms4ms76ms168ms
CPU INT8,4 线程61ms3ms41ms105ms
GPU FP3224ms2ms18ms44ms

CPU 量化之后总耗时从 168ms 降到 105ms,接近 GPU 的一半水平。对于大部分内部系统、自动化流程来说,这个速度已经够用。只有真正的高并发在线服务,才需要把 GPU 方案提上日程。

6. 部署中常踩的坑,以及我的排查顺序

最后这部分是实战里最容易让新人抓狂的坑。我不直接给答案,把我自己的排查链路写出来,按顺序走一遍,大部分问题都能定位。

6.1 检测框全空:先别怀疑模型,回头查预处理

症状很直接:输进去一张清晰的图,检测结果一个框都没有,或者框的位置明显偏移。

我的排查顺序是这样的。第一步,固定输入图片,打印检测模型输出的概率图min和max值。如果概率图的数值范围明显不对,比如全在 0.1 以下,那问题出在预处理,和模型无关。第二步,检查 BGR 和 RGB 的通道顺序。OpenCV 读图默认是 BGR,而 PP-OCR 的预处理按 RGB 来,不转换的话概率分布会整体偏移,阈值 0.3 可能一个框都出不来。第三步,检查 resize 方式。PaddleOCR 用的是直接 resize 到limit_side_len,不是 letterbox。如果用了 letterbox,检测框的坐标会整体偏移,因为模型看到的图和原始图的坐标映射关系变了。

提示:排查这类问题最快的方式,是拿同一张图分别跑官方 Paddle 脚本和你的 ONNX 链路,把中间张量、概率图数值、最终框坐标三方对比,差异出在哪一目了然。

6.2 识别乱码丢字:字符表和解码顺序是重灾区

识别结果乱码,第一反应就去看字符表。打印 logits 的 shape,确认num_classes是否等于 len(dict) + 1(多出的是 blank)。对不上就直接换 dict,别浪费时间调别的。

丢字和多字的问题,九成出在解码细节上。CTC 合并的顺序、blank 的索引、v5 特殊 token 的过滤,这三个点任何一个错了,结果都会很诡异。

还有一类问题是识别输入被拉伸变形。有些实现为了省事,把检测框直接 resize 到3x48x320,不保持宽高比,文字被横向压缩,识别结果必然崩。正确做法是等比缩放到高 48,宽度随文本长度动态变化,超长再按比例压缩。

6.3 动态形状报错与算子不兼容

固定识别宽度 320 的模型,遇到超过 320 宽的长文本行,要么直接报错,要么只在结果里识别出半行字。解决方式两个:导出时把识别模型的宽度设为-1做动态轴,或者在代码里对超长文本做分段裁剪。前者简单,但要确认 ONNX Runtime 版本支持动态形状;后者更稳,适合对稳定性要求高的生产环境。

算子不兼容的问题,主要靠导出参数规避。opset 版本低于 15 时,Resize、Exp 这类算子在 ONNX Runtime 里可能会找不到实现,报错信息很直接。重新用 paddle2onnx 导一次,opset 拉到 15 以上,基本都能解决。

症状排查方向处理方式
检测框全空预处理通道、均值方差、resize检查 BGR/RGB 转换、用官方脚本对拍
框位置偏移resize 方式去掉 letterbox,改直接缩放
识别乱码字符表、blank 索引换回导出时用的 dict,打印 shape 对维度
识别丢字CTC 解码顺序、输入拉伸先合并重复再去 blank,等比缩放
长文本报错或半行固定宽度限制导出时设动态宽度,或分段处理
算子不支持opset 版本低重导到 opset 15+

7. 一些没有写进文档里的实践经验

最后说点个人体会。整个流程里我最看重的是"对拍"这一步——导出之后先别急着上后处理,拿官方脚本和 ONNX 输出对一次,形状、数值分布都要看。这一步能挡住后面至少七八成的坑。别嫌麻烦,ONNX Runtime 和 Paddle 的执行细节差异是客观存在的,早发现早解决,晚发现就等着熬夜排查。

版本锁定这件事也值得单独强调。ONNX Runtime 的 Python 包、C 库、paddle2onnx 工具,三者版本不同,对同一份 ONNX 文件的支持程度可能有差异。部署环境里把版本固定下来,并且和模型文件一起纳入版本管理,能避免很多"在我机器上明明是好的"这类问题。

如果你要部署的是 PP-OCR v4 系列,流程完全一样,只需要把配置里的 dict 路径和模型文件换成对应版本即可。DeploySharp 这套东西的设计初衷就是让模型升级变成配置 diff,而不是重新写一遍部署代码。把精力留给业务本身,部署这件事,做到"可预期、可复现、可追溯"就足够了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 3:31:13

大模型训练全链路:预训练、微调与强化学习实战指南

大模型这几年从一个学术圈里的概念&#xff0c;变成了几乎人人都在讨论的东西。但从“有个模型”到“能用起来”之间&#xff0c;其实隔着一条相当长的流水线。很多朋友拿着开源权重&#xff0c;跑通推理&#xff0c;觉得“模型就是这么回事”&#xff0c;真正动手试过一次训练…

作者头像 李华
网站建设 2026/10/23 3:31:05

MATLAB实现BP神经网络气象预测与天气分析实战

做气象数据分析这段时间&#xff0c;我最大的感受是&#xff1a;天气预报看着简单&#xff0c;实际上是一个典型的非线性、高维度、强耦合的映射问题。这次用MATLAB搭建BP神经网络做气象预测与天气分析&#xff0c;目标很明确——一方面通过多维气象要素的映射关系&#xff0c;…

作者头像 李华
网站建设 2026/10/23 3:30:15

Agent长期记忆系统设计:分层、TTL与数据治理实战

做了几年大模型应用&#xff0c;我越来越确定一件事&#xff1a;Agent落地最难的部分往往不是模型能力不够&#xff0c;而是记忆系统没设计好。用户跟Agent聊到第十分钟&#xff0c;Agent突然忘了五分钟前刚说过的关键条件&#xff0c;这种体验基本等于劝退。但反过来说&#x…

作者头像 李华
网站建设 2026/10/23 3:29:36

DeepSeek-R1推理模型实战:从模型选型到提示语设计

简介&#xff1a;清华大学新闻与传播学院新媒沈阳团队出品的《DeepSeek从入门到精通》电子文档&#xff0c;面向对通用人工智能有研究需求的科研工作者、开发者及广大技术爱好者。内容系统梳理深度求索公司及其开源推理模型DeepSeek-R1的核心能力&#xff0c;覆盖智能对话、文本…

作者头像 李华
网站建设 2026/10/23 3:29:29

文件与目录操作核心指南:路径、命令、Python脚本与权限实战

1. 文件和目录操作的核心思路与适用场景1.1 先搞懂“路径”这件事&#xff0c;后面才不迷路很多新手在接触文件和目录操作时&#xff0c;第一道坎不是命令记不住&#xff0c;而是路径概念稀里糊涂。路径本质上就是“地址”&#xff0c;告诉系统你要找的文件住在哪个目录树下。绝…

作者头像 李华
网站建设 2026/10/23 3:29:25

2026狗尿垫设备综合实力排行与深度测评:智造标准下的产线真相

做宠物护理设备这行久了&#xff0c;总会有人用一副“这有什么技术含量”的表情问&#xff1a;狗尿垫设备&#xff0c;不就是把无纺布、木浆和吸水树脂压在一起裁个边吗&#xff1f;每次我都想拉他们去车间里站一天。同样是“压在一起”&#xff0c;有的线做出来的垫子每片均匀…

作者头像 李华