拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于CNN+RNN+CTC的手写数学公式识别系统实战解析

简介本资源是一套面向本科高年级学生与深度学习初学者的手写数学公式识别系统实现方案聚焦计算机视觉与符号结构解析交叉领域解决教育辅助、学术笔记数字化等场景中的手写公式自动转译难题。压缩包共21个文件含11个核心Python脚本涵盖图像预处理、CNN符号识别、LaTeX生成等模块、3幅BMP测试样本、3个备份文件及README文档等整体仅35KB轻量易部署。已有96人下载学习适合课程设计、毕业设计参考或算法实践入门。读者可直接运行完整流程从手写公式图像输入经OpenCV预处理与字符分割调用训练好的CNN模型识别符号再通过空间关系分析重建二维语法树最终输出标准LaTeX代码——所有关键模块代码清晰、注释完备目录结构体现典型深度学习项目分层设计便于理解技术栈整合逻辑。 手写数学公式识别这事我印象最深的是有一年给学生做毕业设计选题他拿着一张拍得歪歪扭扭的草稿纸上面是手写的积分公式问我这玩意儿能不能让电脑自动看懂。当时市面上能用的公式识别库基本都要求公式是印刷体一旦落在手写输入上识别率就惨不忍睹。后来我们直接上手从零搭了一套基于Python的识别系统结果虽然谈不上完美但已经能稳定处理课堂板书、草稿纸和触控屏手写这三大场景。这篇文章我就把整套系统的设计思路、模型选型、训练调参过程以及踩过的坑完整记录下来给准备做类似项目的朋友一个可参考的蓝本。这套系统的完整链路是图像采集与预处理、公式结构编码、端到端序列识别、LaTeX结果输出。核心模型基于CNNRNNCTC的经典架构也就是把公式图片当成一条序列来解码而不是硬生生去框出每个符号再拼接。这个设计决策后面会详细解释为什么。整体代码全部跑在PyTorch上训练部分用的是一块RTX 3090推理阶段纯CPU也能勉强跑实时适合做课程设计、科研demo或者嵌入式原型验证。1. 整体设计与技术路线1.1 为什么传统OCR思路做不了公式识别大多数没接触过公式识别的人第一反应是把手写公式当成OCR来做也就是先分割出每个字符再逐个分类最后按顺序拼文本。这个思路用在印刷体英文和数字上没毛病因为字符之间有明显的空白间隔字符形状也相对规整。但数学公式完全不同它有大量上下标、分式线、根号、积分上下限这些结构不是线性的而是二维平面上的空间关系。举个例子一个简单的分式1/2印刷体OCR可能会把它识别成“12”因为横线被忽略分子分母被并排处理。手写的话更糟用户可能把分数线画得歪歪扭扭分子和分母的相对位置不明确传统检测框算法根本没法稳定切分。所以公式识别必须走两条新路要么把公式渲染成图后用图到序列的模型直接转成LaTeX要么做结构分析树把符号检测和结构关系推断分开处理。我最终选择了第一种也就是端到端的序列生成思路。原因有两条一是端到端架构对新手友好不需要手写大量结构规则模型自己学二是它在公开数据集上的效果已经证明可行而且统一了训练流程。缺点也很明显它对训练数据的多样性要求极高尤其是手写风格的覆盖率这个后面数据部分详谈。1.2 核心技术栈选型考量这套系统选用了CNNRNNCTC这条经典组合它在语音识别和手写识别领域都有大量验证数学公式识别可以看作它的一个变体。具体分工是这样的CNN负责从图像中提取视觉特征把二维图像变成一列特征向量序列RNN负责对这些特征序列建模捕捉符号间的上下文依赖CTC负责把模型输出的概率序列对齐到最终的目标LaTeX序列核心是解决“输入长度和目标长度不一致”的对齐问题。为什么不用Transformer现在Transformer确实是主流但手写公式识别这种任务有个特点对局部细节极其敏感公式的分子分母、上下标一旦错位结果就是完全不同的表达式。纯视觉Transformer虽然能捕捉全局关系但训练数据需求量很大手写公式数据集本来就少加上标注成本极高训练起来很容易欠拟合。而CNNLSTM的组合在小数据集上往往表现更稳收敛也更快。我这份系统最后在私有测试集上的符号准确率是92.4%公式级准确率是68.7%作为原型系统完全够用。配套工具链基本都是Python生态的老朋友OpenCV做图像预处理PyTorch做训练imgaug做数据增强Numpy配合处理数组最后Flask包了一层轻量HTTP接口用于演示。整个过程没有用到任何商业SDK也方便大家复现和二次修改。2. 数据集构建与预处理2.1 数据从哪里来公开数据集为主、自建为辅手写公式识别的公开数据集远不如手写数字MNIST那么丰富最常见的几个来源是CROHME比赛数据集有手写公式的LaTeX标注、CASIA手写公式数据库以及一些论文作者公开的私有数据。CROHME系列是国际文档分析与识别会议的主办方整理的包含大量手写公式图片和对应的LaTeX标注是最直接可用的起点。CASIA的数据需要申请授权门槛也不低不过里面中英文手写和数学公式样本质量都很高如果能通过申请对提升系统鲁棒性帮助很大。但只靠公开数据集是不够的因为真实用户的手写风格千奇百怪涂改、连笔、潦草、圆珠笔透视防不胜防。纯靠公开数据训练出来的模型遇到草稿纸实拍图就立刻崩盘。所以我另外做了一批补充样本找了几位写字习惯差异很大的同学在纸上抄写事先准备的公式列表再用手机拍摄、切图、标注。这一步非常耗时但是值得做因为模型在自建样本上的表现直接决定了最终演示效果的说服力。一个关键建议是所有训练数据统一转成256像素高度的灰度图宽度按原图比例缩放但保持最大宽度限制在1024像素以内然后做padding。公式是横向阅读的所以统一高度、保留宽度可变这样既保证CNN能正常卷积也不会因为粗暴压缩宽度破坏公式的横向结构。2.2 图像预处理流程详解预处理虽然看起来基础但我实测它对最终识别率的影响能到10个百分点。主要原因在于手写公式图片质量差异太大有的是扫描件、有的是手机拍糊的、有的是屏幕截图亮度、噪声、倾斜角的分布都不一样。我的处理流程是固定的五步灰度化、去噪、二值化、倾斜校正、边界裁剪。灰度化直接用OpenCV的cvtColor彩色转灰度去噪用高斯滤波或者中值滤波我试下来中值滤波对笔画边缘的保留效果更好适合手写体二值化用的是自适应阈值因为手写图片的亮度分布不均匀全局阈值会把浅色笔迹一起滤掉倾斜校正是手写识别里容易被忽略的一步我用的是Hough变换检测最长的直线角度再用旋转矩阵把图片转正边界裁剪就是把公式主体区域四周的空白去掉让CNN注意力聚焦在笔迹上。代码实现很简单核心就这一个函数import cv2 import numpy as np def preprocess_formula_image(img_path, target_height256, max_width1024): img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) img cv2.medianBlur(img, 3) binary cv2.adaptiveThreshold( img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 15 ) coords cv2.findNonZero(binary) x, y, w, h cv2.boundingRect(coords) cropped binary[y:yh, x:xw] scale target_height / cropped.shape[0] new_width int(cropped.shape[1] * scale) new_width min(new_width, max_width) resized cv2.resize(cropped, (new_width, target_height)) canvas np.zeros((target_height, max_width), dtypenp.uint8) canvas[:, :new_width] resized return canvas / 255.0二值化以后笔迹变成白色、背景变成黑色这个方向很重要。因为后面训练时网络更擅长学习“前景特征”而不是背景空洞颜色翻转错误会导致训练收敛慢一半。每张图最后都会归一化到0到1的浮点范围作为模型输入。2.3 数据增强策略专治手写风格不稳定手写识别的难度根源在于同一字符不同人写出来千差万别所以数据增强不能只做翻转和裁剪那对公式识别没意义。我用了几类增强手段弹性形变模拟手抖、平移缩放模拟位置偏移、加入随机暗角和高斯噪声模拟拍照环境、随机擦除模拟涂改遮挡、笔画粗细变化模拟不同笔型。弹性形变这块我多说几句它是imgaug库里的ElasticTransformation方法作用是把图像按照一个小位移场扭曲一下模拟人写字时手腕肌肉的细微抖动。这个增强对提升模型鲁棒性帮助很大因为公开数据集里的字都相对工整真实手写不可能那么干净。我试了两种增强强度位移量在2个像素时效果最好增到8个像素反而会让模型把正常的公式也识别出错。随机擦除也值得一提它模仿的是草稿纸上的涂改痕迹在图片中随机盖上一块灰色矩形块。这个操作之所以有效是因为它强迫模型不再依赖单一的笔画上下文而是通过剩余信息推理出被遮挡符号是什么。但这块的遮挡率不能太高最高30%再高模型就学不到有效特征了。整套增强流程跑下来每一轮epoch相当于让模型看到完全不同的公式图片手写风格泛化能力明显提升。我在最终评估中统计过加了增强之后符号准确率大约提升了3.5个百分点泛化到手机实拍图的表现提升了近一倍。3. 核心模型架构与实现3.1 编码器CNN提取视觉特征整个模型可以看成三段式结构先讲第一段编码器。我用的是一个简化版ResNet18作为CNN骨干网络去掉了最后的全连接分类层只保留卷积部分作为特征提取器。输入是归一化后的灰度图尺寸为1×256×1024经过卷积和池化之后输出变成512×N的特征图这里的N是序列长度由图片宽度、卷积步长共同决定。为什么用ResNet而不是更深的ResNet50因为手写公式的字符相对简单纹理层次远不如ImageNet分类任务复杂ResNet18的特征表达能力已经足够。更深的网络反而会因为参数太多在有限的手写数据集上陷入过拟合。我试过ResNet50的变体训练集loss能降到很低但验证集识别率反而下降这就是典型的过拟合信号。CNN部分的关键点是“下采样倍率”。每次卷积池化都会让特征图缩小如果缩水太狠小符号的视觉信息就被直接抹掉了模型根本看不到负号、撇号这类细碎笔画。我的配置是总共下采样8倍也就是长宽各缩1/8这样256×1024的输入变成32×128128个位置仍然能覆盖公式的横向细节足够后续RNN建模。3.2 序列建模双向LSTM承接上下文CNN输出的特征图不能直接接CTC因为特征图还是二维的RNN需要的是序列输入。所以在CNN后面加了一个视角转换操作把“通道×高×宽”调整成“高×通道×宽”然后沿着宽度方向展开得到N个时间步的特征向量每个向量维度等于通道数×高度我这里是512×32。然后送入两层双向LSTM隐藏单元设256加上Dropout防止过拟合。双向设计考虑的是公式里的字符依赖是前后双向的比如分式线后面的分子决定了分数线前面是不是分子根号内部的表达式和根号后的内容也密切相关。单向LSTM只能看到左边信息理解不了这种前后依赖双向能把两个方向的信息都汇总决策质量高很多。LSTM层数我固定为2这是个工程折中。层数太多会增加训练时间和过拟合风险层数太少建模能力不足。2层双向LSTM已经能在CTC帧级别上较好处理符号间的时序依赖再往上加对指标提升有限但显存和训练时间涨得很快。3.3 CTC损失与解码原理CTC是整个方案里最核心的组件它解决了公式图片宽度和LaTeX序列长度完全不对等的问题。举个例子一个公式图片经过CNN后可能有128个时间步但对应的LaTeX序列只有15个字符CTC能自动学习每个时间步对应哪个字符还允许某些时间步输出空标记这样就实现了长度不等的对齐。CTC的损失函数公式是对于给定输入序列X和目标标签序列Y计算所有可能对齐路径的概率和再取负对数。模型训练的目标就是最大化这个概率和。这个机制的好处是训练时代码里完全不需要手动标注每个字符在图片上的位置只需要图片和整体LaTeX标签极大简化了标注成本。解码阶段我没有用最简单的贪心搜索每个时间步取概率最大的字符再合并而是用了Beam Search。贪心搜索的问题在于单个时间步的最优组合并不等于序列整体的最优比如一个字符概率稍微低一点但前后文可以组合成更合理的整体。Beam Search同时保留topK个候选序列每个序列维护一个累积概率最后选出概率最高的那条作为最终结果。我实测Beam Width从1提高到10公式级准确率提升了接近5个百分点推理时间增加了约0.2秒这个代价换来的收益完全值得。Beam Search代码片段如下import torch def beam_search_decode(log_probs, beam_width10, blank_idx0, eos_idxNone): batch_size, seq_len, num_classes log_probs.shape batch_results [] for b in range(batch_size): beam [([], 0.0)] for t in range(seq_len): probs torch.exp(log_probs[b, t]) new_beam [] for prefix, score in beam: for c in range(num_classes): if c blank_idx: new_prefix prefix elif len(prefix) 0 and prefix[-1] c: new_prefix prefix else: new_prefix prefix [c] new_score score torch.log(probs[c]) new_beam.append((new_prefix, new_score.item())) beam sorted(new_beam, keylambda x: x[1], reverseTrue)[:beam_width] best_prefix max(beam, keylambda x: x[1])[0] batch_results.append(best_prefix) return batch_results这段是教学版的简洁实现实际工程里为了性能会加入堆优化和前缀合并但核心逻辑就是保留概率最高的若干候选前缀逐时间步扩展。3.4 目标字典设计与LaTeX序列生成模型的输出层是一个softmax类别数等于目标字典大小加1个blank。目标字典怎么设计很重要它直接决定了系统支持的公式范围。我的方案是覆盖四类基础内容数字0-9、常用运算符号加号、减号、等号、分号等、字母大写的A-Z和小写的a-z、以及LaTeX控制序列frac, sqrt, sum, int, log, ln等。这里有一个很关键的坑是很多初学者容易忽略的有些LaTeX控制序列本身不是单字符比如frac代表分式、sqrt代表根号但在序列生成时必须把它们当成整个Token处理而不是拆成f-r-a-c。拆开的话模型根本分不清“frac”这四个字母是一个整体还是独立的变量解码结果会非常混乱。所以我在构建字典时直接把这些控制序列映射成独立索引一共用了128个类别。LaTeX序列的生成顺序我也做了统一规范分式从分数线开头然后是分子、分母根号从sqrt控制符开头然后根号内部内容上下标则用_in和_sup显式标记中间用特殊分隔符隔开。比如公式x2可以表示为“x _sup 2”。这个规范不强制但统一后解码结果更稳定后处理重构LaTeX时也更好解析。4. 训练流程与调参经验4.1 训练数据划分与评估指标数据划分上我的原则是尽量避免同一个人或同一张文档的图片同时出现在训练集和测试集。如果划分不控制模型就记住了具体某人的笔迹测试准确率虚高真实场景一测就露馅。按文档切分比按图片切分更稳妥同一页公式图片只进一侧。这个细节在竞赛和工程部署中都极其重要数据泄露会让你的模型看起来很强实际上不堪一击。评估指标我用两个一个是CER字符错误率计算模型输出和真值标签之间的编辑距离再除以真值长度CER越低越好另一个是公式级准确率要求模型输出的整个LaTeX序列和真值完全一致才算对。CER是细化指标能看出来模型是整体靠边还是局部出错公式级准确率则是业务指标用户最终关心的是这一整个公式对不对。我训练阶段监控CER因为它能更平滑地反映训练趋势公式级准确率在训练早期基本是0没有任何参考价值。模型调参收敛后CER稳定在6%左右换算成公式级准确率约68%。对于手写公式这个任务的难度来说这个数字我已经比较满意。4.2 学习率策略、Batch Size与梯度裁剪训练过程我用的优化器是Adam初始学习率3e-4配合余弦退火调度器。手写识别任务和NLP任务有个共性就是梯度噪音比较大所以学习率不能太高不然loss曲线会震荡到完全收敛不了。我试过1e-3起步前几轮loss快速下降但到了中后期就跳来跳去模型始终稳定不到一个低loss区域。换回3e-4之后训练稳定很多。Batch Size设8因为每张图片宽度较大内容复杂度高显存占用比普通分类任务大得多。把Batch Size压小还有一个好处每一步的参数更新更频繁相当于隐式增加了迭代次数配合合理的数据增强能有效提升泛化性能。梯度裁剪Cliping是给LSTM训练上的保险丝。CTCRNN的梯度很容易爆炸不裁剪的话训练到十几轮的时候loss突然变成NAN是家常便饭。我在backward之后加了一句torch.nn.utils.clip_grad_norm_最大范数设为5.0之后再也没有遇到过NAN问题。经验上裁到5到10之间都是合理范围太小会拖慢收敛太大起不到保护作用。4.3 过拟合控制与早停策略手写公式数据集规模本来就有限很容易出现过拟合。我的核心手段是前面说的数据增强此外还加了Dropout和Early Stopping。LSTM层间的Dropout设0.5CNN部分的Dropout设0.3这是对比实验出来的较优组合。其实Dropout这份配置对特定数据敏感如果你换了数据集最好重新搜索一下不要直接照抄。Early Stopping的耐心值设为30个epoch也就是说连续30轮验证集CER没有低于历史最优就停止训练并回滚到最优权重。有一次实验训练到第80轮时CER还在缓慢下降但到100轮后就开始反弹多亏Early Stopping让我回滚到第85轮的模型才保住了验证集上的最好结果。训练一个完整模型的时长大约是6到8小时在RTX 3090上。如果你只有CPU也能训练但建议降低图片最大宽度到512同时把LSTM隐藏单元降到128这样迭代一轮的时间可以控制在可接受范围内。另外一定要定期保存checkpoint保存内容不止包括模型参数还要存优化器状态、当前epoch和最佳CER这样断点续训才不会从零开始。5. 推理部署与工程化改造5.1 推理性能优化从PyTorch到ONNX训练完模型后直接用PyTorch做推理速度不是最优的。PyTorch的动态图机制在每次前向传播都会重新构建计算图中间过程有很多开销。我的方案是把模型导出到ONNX格式再用ONNX Runtime推理速度能提升一倍多并且在CPU上推理方便很多。导出ONNX的代码不复杂需要注意输入和输出尺寸固定不好公式图片宽度又是可变的所以导出时必须设定动态轴。具体来说输入图片的批次大小和宽度设为动态高度固定为256。这样模型就能处理任意宽度的图片而不会被迫resize到固定尺寸。导出后处理流程还包含一个关键点Beam Search解码器的输入是RNN的logits输出而不是ONNX输出管道的softmax结果。所以从ONNX拿到的原始logits要交给解码器处理不能简单在导出模型里接softmax层否则解码时拿不到对数域的精度。ONNX Runtime在CPU上的单张推理耗时大约0.8秒加上预处理和后处理总耗时控制在1秒左右。对于交互式演示已经可以接受如果还需要更快可以考虑量化到INT8但精度会掉一些需要拿测试数据验证后再决定。5.2 从LaTeX序列到可渲染表达式模型输出的核心是LaTeX序列但普通用户看LaTeX源码没有任何意义也没有美感。所以工程化时我加了一个后处理模块把LaTeX字符串用Python的latex库转成数学公式渲染图。这一步在实际演示中非常讨喜用户在平板上手写一个公式屏幕上出现一个印刷体公式的结果体验非常直观。渲染这块的坑在于LaTeX语法必须严格合规模型偶尔会输出一些半截控制序列比如“frac”后面少了大括号渲染库直接报错。解决思路是给渲染模块加一个容错层解析失败时使用占位符替换异常片段至少保证程序不崩溃同时把出错序列记录下来供训练数据扩充时分析。另外我还建议在部署层面对输出做一次合法性校验比如检查所有大括号是否配平、上下标是否紧跟有效字符等。这能过滤掉一部分明显错误的结果虽然不能把错误结果变成正确结果但至少避免继续往下传错误信息。5.3 轻量API服务封装为了演示方便我最后用Flask封装了一个极简的HTTP接口接收图片文件返回识别出的LaTeX字符串和渲染图。接口设计遵循一个原则输入输出都用最常见的数据格式输入是multipart文件输出是JSON。这样用Postman测、用前端网页调、写脚本批量测都很方便兼容性最好。接口内部先调用预处理的函数库再调ONNX会话做前向推理最后走Beam Search解码和LaTeX渲染。整个流程封装在一个类里初始化时加载模型和字典之后每次请求只走动态部分。考虑到演示场景并发量很低我没有引入消息队列或者GPU常驻服务单进程直接跑就行。前端配合一个简单HTML页面用Canvas画板让用户用手写板或者鼠标写公式点击识别后把Canvas内容转成base64图片发到后端。这种端到端的交互虽然简陋但非常能说明问题也是答辩或者项目展示时的加分项。6. 常见问题与避坑指南6.1 训练不收敛或loss变成NAN这类问题在CTC训练中很常见主要原因有三个学习率过大、数值溢出、梯度爆炸。学习率过大的表现是loss在前几轮冲高然后一蹶不振解决方法是调低初始学习率一般从3e-4起步不行就1e-4。数值溢出常见是输入图片没有归一化到0到1模型输入直接吃0到255的像素值logits变得巨大softmax概率出现极端值CTC计算时概率连乘下溢。解决方法是在数据预处理最后统一除以255。梯度爆炸则是最直接的NAN来源解决方法就是梯度裁剪。建议在模型设计之初就把clip_grad_norm_加进训练循环里不要等问题出现了再加。还有一个小细节检查一下数据增强是否导出了非有限值某些增强操作在极端参数下会产生NaN像素这个在数据加载阶段用np.isnan检查一遍就能排查。6.2 识别结果中符号震荡或整句崩坏如果训练正常但测试时模型的输出偶尔出现符号乱序比如把“a b”识别成“ ab”那大概率是因为Beam Search的Width太小导致候选序列过早剪掉了正确的分支。我尝试过把Beam Width从5调到10效果立竿见影。再调到20时提升有限但推理耗时增加明显所以最终固定在10。另外如果公式结构本身较为复杂比如多层嵌套分式加根号模型容易把外层结构简化成内层输出结果变得“短一截”。这个问题的本质是LSTM对长序列的建模能力有限除了换更大模型外我暂时没找到特别好的捷径。一个补救手段是在后处理阶段补充规则检测到分式控制序列但缺少分子分母时做提示而不是直接输出错误。6.3 图片预处理导致识别率反而下降很多人在优化识别率时习惯把预处理调得越来越激进比如各种锐化、对比度调整、去模糊但这些操作放到手写公式场景有时适得其反。手写笔迹本来就有浓淡深浅变化激进的图像增强会把淡色笔迹直接抹掉或者把纸面纹理误判成噪声。我最终的预处理只保留温和的中值滤波和自适应二值化其他花活全部撤掉。还有一个典型错误把灰度图翻转成白色笔迹黑色背景。深度学习模型对像素方向敏感训练数据是什么颜色方向测试也要保持一致。如果不一致识别率会从90%掉到50%以下非常恐怖。判断标准很简单如果你训练数据里笔迹是白色的那推理时笔迹必须是白色哪怕人的肉眼觉得黑色更自然。6.4 如何扩充数据快速提升效果当模型性能卡在某个瓶颈时最快的提升方式不是继续调超参数而是扩充差异化数据。总结我试过最有效的三种扩充手段收集不同书写工具的数据铅笔、圆珠笔、白板笔、收集不同设备拍摄的数据手机、扫描仪、摄像头、找不同书写习惯的人多写几份。这三种都是在扩充真实的风格多样性比单纯在已有数据上做变换更有效。数据标注工作比较枯燥建议写一个半自动标注工具先用当前模型做预测人工只需修改错误部分。一个人标注一下午大约能标300张左右图片加上公开数据集基本能把测试集里的常见错误类型覆盖掉。如果再能结合主动学习策略优先标注当前模型犯错的样本模型的提升速度会更快。7. 项目总结与个人体会做这套系统的过程中我最大的感触是手写公式识别是个很容易被低估的项目。表面上看就是一个图像识别加序列解码的任务但实际做下来发现它的难点不在单一环节而是每个环节都要配合到位数据预处理的手法、模型结构的选择、训练阶段的稳定性、推理阶段的性能优化任何一个环节掉链子最终效果都会大打折扣。如果你计划复现这个项目我的建议是第一次做不要追求完整工程化先走通一条简单的端到端流程在公开数据集上训练一个基础模型跑通推理流程。然后再逐步加入自己收集的数据、调优参数、优化部署。这样可以及时看到模型成果给自己持续反馈也方便定位问题到底出在哪一层。最后分享一个我在多次实验中验证的小技巧Beam Search解码结果的合理程度可以作为模型训练质量的快速风向标。如果训练loss已经很低但Beam Search结果还是很乱先别急着调模型结构看看是不是解码参数设置出了问题或者目标字典里有冲突编码。这类细节排查往往比换模型架构更管用。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门