ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于Mediapipe骨架与LSTM的手语识别实战:从数据采集到实时推理

基于Mediapipe骨架与LSTM的手语识别实战:从数据采集到实时推理 简介基于mediapipe的手语识别Python项目适用于毕业设计、课程期末大作业或计算机视觉方向入门实践。项目覆盖静态手语与动态手语识别利用mediapipe完成手部关键点提取并以LSTM、GRU两类循环神经网络完成模型训练与推理同时提供gradio交互界面便于演示。整套资源共21个文件以Python脚本、训练日志图表、已训练模型文件和说明文档为主压缩包总体积约9.39MB模型文件覆盖不同网络结构与序列长度可对比不同参数下的识别效果。目前已有377人在线学习浏览源码均经过本地编译运行验证并包含数据集生成、模型训练、推理检测等完整流程适合需要快速搭建手势识别系统或完成毕设方案的读者参考。1. 手语识别没有想象中难mediapipe 骨架 时序模型是毕业设计的稳妥起跑线一个基于 mediapipe 的手语识别 python 源码全部数据的压缩包真正解决的问题是把手语识别从我得读懂一篇姿态估计论文再动手降级成我只要关心坐标序列怎么分类。mediapipe 在 CPU 上就能输出 21 个手部关键点视频里的手变成了一段 63 维的时间序列剩下的训练任务就是给序列打标签。这套路线不需要昂贵 GPU数据可以自己录代码量完全压得住毕业设计的篇幅。适合三类人想快速复现可用 Demo 的毕业生、第一次接触动作识别的课程设计者、以及想确认这条路值不值得投入的开发者。这篇笔记把从安装、采集、训练到答辩验证的完整链路拆开讲清楚。2. 先用 mediapipe 把视频变成骨架序列从安装到第一批数据2.1 为什么选 mediapipe而不是直接训练图像分类模型手语的表达方式决定了静态分类不够用同一个词根加上不同的移动轨迹、速度和重复次数含义完全不同而两个不同的手势停在同一帧上像素差异可能很小。如果拿原始视频帧直接训练 3D-CNN先不说显存单是收集上万段干净视频、逐段标注的成本已经超出了毕业设计的时间预算。常见做法是先在每一帧上做手部关键点检测把问题降维。mediapipe 的 Hands 模块是这条路上最省事的入口它直接把 21 个关键点的归一化坐标交给你包含 x、y 和相对深度 z参与分类通常只用前 63 维21 点 × 3 坐标。CVPR 上不少手势识别工作走的也是骨架提取 时序建模路线区别只在骨架来源和时序模型的选择上。选 mediapipe 还有一个现实理由它对部署环境几乎不挑Windows、macOS、Linux 都能跑CPU 就能做到实时检测。相比之下自己训练一个手部关键点检测模型需要标注几万张手部图片这条路对个人开发者基本不现实。mediapipe 相当于把黑匣子开放成接口你尽管在接口外面做文章。提示mediapipe 对 Python 版本有要求装之前先确认自己的 python 版本在 3.8~3.11 之间太新或太旧都可能出现 pip 找不到对应 wheel 的情况。2.2 环境准备与最小骨架提取脚本环境问题是最容易被卡住的地方。装好 python 之后一条命令装齐全部依赖pip install mediapipe opencv-python tensorflow2.13.0mediapipe 负责手部关键点opencv 负责读摄像头和窗口显示tensorflow 负责后面的模型训练。tensorflow 版本建议固定因为 mediapipe 某些版本和最新的 tensorflow 存在 protobuf 冲突固定 2.13 能少踩一个坑。如果机器有 NVIDIA 显卡tensorflow 会自动识别并调用 GPU没有也不影响CPU 训练这个小模型完全可接受。先写一个最小脚本验证 mediapipe 能跑通再往下做数据就顺了import cv2 import mediapipe as mp mp_hands mp.solutions.hands hands mp_hands.Hands( static_image_modeFalse, max_num_hands2, model_complexity1, min_detection_confidence0.5, min_tracking_confidence0.5, ) cap cv2.VideoCapture(0) while cap.isOpened(): ret, frame cap.read() if not ret: break frame_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results hands.process(frame_rgb) if results.multi_hand_landmarks: # 取检测到的第一只手作为当前样本 hand results.multi_hand_landmarks[0] keypoints [[lm.x, lm.y, lm.z] for lm in hand.landmark] print(len(keypoints), keypoints[0]) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()逻辑说明hands.process 接受 RGB 图像所以用 cv2 读到的 BGR 帧必须先转成 RGB否则检测率会明显下降。keypoints 里存的是归一化坐标x 和 y 相对于画面宽高z 是以手腕为原点的相对深度单位不是像素训练时不需要再做归一化。参数说明static_image_mode 在视频流里必须设 False这样会启用帧间跟踪速度更快max_num_hands 设为 2 是考虑打手语时可能出现双手前期统一用单手可以设 1。model_complexity 有 0/1 两档0 速度更快但关键点抖动更大实时演示建议 1。min_detection_confidence 低于 0.5 时容易出现误检高于 0.7 又会频繁丢手0.5 是适合大多数场景的起点。2.3 攒数据把一段动作变成一个固定长度的骨架序列样本手语识别模型喂进去的不是一帧而是一段序列。一个样本在磁盘上存成一个 npy 文件形状是 (序列长度, 21, 3)记录这个动作从开始到结束的完整轨迹。目录结构直接按类别组织一个文件夹一个词dataset/ ni_hao/ sample_0001.npy sample_0002.npy xie_xie/ sample_0001.npy zai_jian/ sample_0001.npy采集时每做一个动作就存一个文件。关键点是每个动作录多少帧保持一致我一般用 30 帧大约一秒的动作长度。核心采集逻辑如下import os import cv2 import mediapipe as mp import numpy as np mp_hands mp.solutions.hands hands mp_hands.Hands( static_image_modeFalse, max_num_hands1, model_complexity1, min_detection_confidence0.5, min_tracking_confidence0.5, ) SEQUENCE_LENGTH 30 def collect_one_sample(cap, save_path): sequence [] while len(sequence) SEQUENCE_LENGTH: ret, frame cap.read() if not ret: continue frame_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results hands.process(frame_rgb) if results.multi_hand_landmarks: hand results.multi_hand_landmarks[0] kp np.array([[lm.x, lm.y, lm.z] for lm in hand.landmark]) sequence.append(kp) # 手没被检测到时直接跳过这一帧不写入序列 sample np.array(sequence) # (30, 21, 3) np.save(save_path, sample)这个采集逻辑有个点值得说明当手短暂离开画面时跳过而不补帧。如果中间缺了十几帧这个样本就不完整训练时模型会把手消失当特征学进去。检测失败的帧不算数宁可动作稍微放慢也不往序列里塞空数据。save_path 按类别组织文件名用四位数字编号方便后续按文件名排序与划分训练集。有一点容易被漏掉所有样本必须统一左右手。如果你好这个类别里一半样本用右手比一半样本用左手比模型学到的将是左右手手型混合的噪声推理时经常出错。我自己的办法是采集前固定只用右手所有类别统一录入如果原词习惯左手就镜像翻转后再录入。每个类别建议至少收集 40 个样本。40 这个数字来自实践少于 30 个LSTM 很容易过拟合多于 60 个采集时间成本翻倍但准确率增长变缓。路径命名不要用中文npy 文件名统一用六位数字这样切分时按字符串排序结果稳定。另外采集中途如果发现某一类动作经常检测不到手先调整摄像头角度而不是硬录废样本混进数据集里比样本少更麻烦。3. 骨架序列怎么变成分类结果LSTM 训练代码与三个必调参数3.1 时序模型选型为什么默认答案是 LSTM什么时候换拿到 (30, 21, 3) 的序列后剩下的是序列分类问题。可选的模型很多LSTM、GRU、TCN、Transformer甚至 CNN 加时序池化。毕业设计与课程设计里LSTM 是默认答案理由很朴素它的输入输出结构最直观在 keras 里写起来不到二十行数据量小的时候RNN 类模型比 Transformer 更容易收敛训练和推理资源占用都低CPU 也能在几百毫秒内完成一个样本的预测。我在实际做的时候体会是LSTM 不是精度上限最高的模型却是综合成本最低的。如果你的动作里带有明显的速度变化LSTM 的隐藏状态天然能感知这种节奏差异。TCN 在长序列上表现更好但调它的卷积核宽度和空洞率需要时间Transformer 在小数据集上容易过拟合没有大规模预训练权重加持时反而不如 LSTM 稳。顺带一提mediapipe model maker 提供了自定义图像分类的流程但它是为静态图片设计的对时序动作支持有限手语里有大量移动轨迹类词汇所以这里不用它直接用 tf.keras 写 LSTM 更可控。3.2 数据加载与 LSTM 训练代码直接抄的 keras 实现先把 npy 目录读进内存。注意把 (30, 21, 3) 展平成 (30, 63)让每个时间步的特征是这一帧 21 个关键点的全部坐标import os import numpy as np def load_dataset(data_dir, seq_len30): X, y [], [] label_names sorted(os.listdir(data_dir)) label2id {name: i for i, name in enumerate(label_names)} for label in label_names: label_dir os.path.join(data_dir, label) for fname in os.listdir(label_dir): sample np.load(os.path.join(label_dir, fname)) sample sample.reshape(seq_len, -1) # (30, 21*3) X.append(sample) y.append(label2id[label]) return np.array(X), np.array(y), label2id, {v: k for k, v in label2id.items()}逻辑说明reshape 把每个时间步上 21 个点的 x/y/z 展平成 63 个连续特征时间顺序保持不变。这个展平操作必须在数据加载时做而不是在模型里做这样 sklearn 的 train_test_split 才能直接切分。label2id 和 id2label 同时保留后面推理和画混淆矩阵都要用。训练代码用 keras 的 Sequential 组织结构上两层 LSTM 加一层全连接import tensorflow as tf from tensorflow.keras import layers, models from sklearn.model_selection import train_test_split def build_model(seq_len30, feat_dim63, num_classes10): model models.Sequential([ layers.Input(shape(seq_len, feat_dim)), layers.LSTM(128, return_sequencesTrue), layers.Dropout(0.3), layers.LSTM(64), layers.Dense(64, activationrelu), layers.Dropout(0.3), layers.Dense(num_classes, activationsoftmax) ]) model.compile( optimizertf.keras.optimizers.Adam(learning_rate0.001), losssparse_categorical_crossentropy, metrics[accuracy] ) return model X, y, label2id, id2label load_dataset(dataset) X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) model build_model(num_classeslen(label2id)) model.fit( X_train, y_train, validation_data(X_val, y_val), epochs60, batch_size32, callbacks[ tf.keras.callbacks.EarlyStopping( monitorval_loss, patience10, restore_best_weightsTrue ), tf.keras.callbacks.ReduceLROnPlateau( monitorval_loss, factor0.5, patience5, min_lr1e-5 ) ] ) model.save(hand_sign_model.h5)逻辑说明第一层 LSTM 设置 return_sequencesTrue目的是让第二层 LSTM 接受到完整的时间步输出最后一层 softmax 把输出变成每个类别的概率。sparse_categorical_crossentropy 配合整数标签用不需要把 y 转成 one-hot。参数说明LSTM 单元数取 128 和 64是对 30 帧序列比较稳妥的配置少了学不动多了在小数据上过拟合。Dropout 0.3 放在两层之间抑制过拟合。batch_size 32 在数据量几百到几千时都合适。EarlyStopping 的 patience 设 10意思是验证集 loss 连续 10 个 epoch 不降就停restore_best_weights 保证保存的是最优权重而不是最后一次的权重。ReduceLROnPlateau 在验证 loss 停滞后自动减半学习率min_lr 设 1e-5 防止减到零。3.3 三个必须调的参数序列长度、类别均衡和学习率先说序列长度。30 帧是按每秒 30 帧摄像头推流算的覆盖约 1 秒的动作。手语里一个词的动作通常在 0.5 到 1.5 秒之间如果发现某类动作特别长要把 SEQUENCE_LENGTH 提到 45 或 60此时模型参数量不变只是计算量线性增加。序列长度如果超过动作实际长度多余的帧是手停在结束位置等于往样本里塞了大量结束态会让模型倾向于提前判定。再说类别均衡。手语识别里最容易犯的错是每类样本量差很多谢谢录了 100 个你好只录了 20 个。模型会倾向于预测样本多的类训练集准确率虚高。解决办法不是删数据是先给每个类别定最低样本数比如每类至少 40 个然后训练时给样本少的类更高的 class_weightkeras 里直接把 class_weight 参数传给 fit 就行。学习率这块0.001 是 Adam 在 LSTM 上的常规起点但如果你发现训练一开始 loss 就在震荡先调到 0.0003而不是换模型。反过来如果 loss 前 5 个 epoch 下降非常慢再回到 0.001。手语序列数据量小学习率对收敛速度影响很明显值得单独试两轮。4. 手语识别常见问题与排查让我返工最多的 6 个坑这几条不是从教科书里抄来的是我自己在这个项目上真实返工过的记录。每一条都按现象、原因、解决三个层次写方便你对号入座。4.1 训练集准确率 99%测试集只有 60%现象模型在训练集上几乎不犯错拿到自己新录的几段视频一测约四成识别错误。原因收集数据时背景、光照和拍摄距离单一模型学到的是在这个宿舍、这个灯光、这个坐姿下的特征而不是手的动作。骨架坐标虽然已经是归一化数值但手在画面里的位置分布、手的尺寸以及手腕以下的背景干扰都会被当成隐性特征。解决采集时故意变化环境。我的做法是分三天采集每天换个位置并且手距离摄像头在 30 到 70 厘米之间随机变化。测试时也用完全没参与训练的视频来做评估而不是拿着训练数据里抽出来的一部分当测试集。这个坑的教训是手语识别的测试集必须独立采集不能只做随机切分。4.2 左右手关键点串了现象对手语稍有了解的人一看结果就说不对模型把左手动作识别成了另一个词而且经常是镜像相关的词。原因采集时没有统一左右手或者 max_num_hands2 时代码取了第 0 号手而 mediapipe 输出的手顺序不保证每次都是同一只。解决先把所有类别统一为右手采集和推理都用同一只。如果习惯用左手把画面做镜像翻转。推理代码里不要用 multi_hand_landmarks[0] 直接取而是用 multi_handedness 判断左/右标签或者干脆把 max_num_hands 设为 1从根本上避免取错手。我自己后面统一成 max_num_hands1这类问题再没出现过。如果确实需要支持双手推理时用 results.multi_handedness[i].classification[0].label 判断每只手的标签label 是 Left 或 Right。取关键点时先判断是左手还是右手再决定是否做水平翻转。大部分手语词用的是惯用手另一只手要么不动要么做对称动作所以我在数据集里只保留惯用手的序列非惯用手直接丢弃。4.3 手稍微出画面预测结果就乱跳现象手移动到画面边缘、指尖短暂超出边框时实时窗口里的预测标签在几个词之间快速跳变。原因mediapipe 在关键点部分缺失时仍会输出估计坐标但边缘帧的坐标噪声很大滑动窗口里只要混入几帧边缘噪声LSTM 的输出概率就会显著摆动。解决两个手段一起用。第一推理时对概率输出做平滑不直接显示当前帧的 argmax而是维护一个最近几次预测的平均概率再取最大第二置信度低于阈值时保持上一次的显示结果不更新标签。我通常把阈值设 0.7低于它说明手型可疑宁可让它停留在上一个结果。这个平滑思路在下一章会展开。4.4 验证集 loss 不降反升训练 loss 继续降现象第 20 个 epoch 开始训练 loss 稳步下降验证 loss 从 0.4 一路涨到 1.2。原因过拟合而且在这类小数据集上过拟合来得非常快。样本量每类只有三四十个时两层 LSTM 的容量已经偏大。解决加 Dropout、加早停、扩数据三选二。我更推荐先扩数据再加 Dropout因为手语数据采集成本低每类加到 60 个样本往往比调模型结构更有效。如果实在没时间录把已有样本做时序的时间偏移增强——把 30 帧序列整体往前或往后偏移 3 帧相当于制造动作起点略不同的新样本这个增强对手语很有效。4.5 模型只认识录数据的人的手现象采集的人是男生换一个手型较小的女生测试识别率明显下降换肤色不同的人下降更明显。原因手部关键点的归一化坐标已经减少了尺度差异但动作幅度、速度和个人习惯仍然存在。手语动作本身是有个人风格的逐帧坐标序列会把这些风格差异带进模型。解决多人采集这是最直接的办法。每人每个词录一部分把 5 个人的数据混在一起训练。如果只靠自己一个人就在录制时故意变化动作幅度和速度比如快慢各录一半。这一点是手语识别项目里最容易被忽视的也是答辩时最容易扣分的地方。4.6 摄像头实时推理只有 5 帧每秒现象代码逻辑没问题但窗口画面明显卡顿模型给出预测的间隔很长。原因mediapipe 的检测加 LSTM 推理都是 CPU 计算而且每一帧都在做 model.predictpredict 在 keras 里有固定的调用开销哪怕输入只有 30×63。再加上窗口显示和摄像头读取都在同一个线程里帧率被拖慢是必然的。解决三步优化。第一把 process 的输入分辨率降低cap.read 后先 resize 到 480 宽再送进 mediapipe。第二每 3 帧做一次预测中间帧用上一次结果顶替。第三用 model.predict_on_batch 替代 model.predict减少函数调用开销。实测这三步能把有效帧率提升到接近 20 帧每秒实时演示完全够用。5. 把模型接上摄像头实时推理的完整流程与平滑策略5.1 实时推理主流程滑动窗口与预测缓冲离线训练完成后实时推理的流程与采集阶段几乎一致差异只在推理端多了一个滑动窗口和一个平滑层。滑动窗口维护最近 30 帧的关键点序列相当于一个最近一秒的手部轨迹模型对这个窗口预测类别。下面是可以直接跑的推理脚本核心部分import cv2 import mediapipe as mp import numpy as np import tensorflow as tf model tf.keras.models.load_model(hand_sign_model.h5) mp_hands mp.solutions.hands hands mp_hands.Hands( static_image_modeFalse, max_num_hands1, model_complexity1, min_detection_confidence0.5, min_tracking_confidence0.5, ) WINDOW_SIZE 30 CONF_THRESHOLD 0.7 buffer [] missed_frames 0 current_label cap cv2.VideoCapture(0) while cap.isOpened(): ret, frame cap.read() if not ret: break frame cv2.resize(frame, (640, 480)) frame_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results hands.process(frame_rgb) if results.multi_hand_landmarks: missed_frames 0 hand results.multi_hand_landmarks[0] kp np.array([[lm.x, lm.y, lm.z] for lm in hand.landmark]).flatten() buffer.append(kp) if len(buffer) WINDOW_SIZE: buffer.pop(0) if len(buffer) WINDOW_SIZE: inp np.array(buffer).reshape(1, WINDOW_SIZE, -1) prob model.predict(inp, verbose0)[0] pred_id int(np.argmax(prob)) conf float(prob[pred_id]) if conf CONF_THRESHOLD: current_label id2label[pred_id] else: missed_frames 1 if missed_frames 5: buffer.clear() cv2.putText(frame, current_label, (30, 50), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 0), 2) cv2.imshow(Hand Sign Recognition, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()逻辑说明每一帧检测出手部关键点后把 21×3 展平为 63 维向量 append 到 bufferbuffer 长度超过 30 时弹出最旧的一帧保持窗口始终是最近 30 帧。只有当 buffer 恰好达到 30 帧时才做预测避免半截窗口的无效推理。检测不到手时用 missed_frames 计数连续 5 帧丢手才清空 buffer避免手短暂离开画面就丢失整个轨迹上下文。参数说明resize 到 640×480 是精度与速度的折中降到 320 宽会更快但关键点抖动明显。CONF_THRESHOLD 设 0.7 是经验值动作幅度小的词如谢谢概率会更分散可以降到 0.6动作区分度大的可以提高到 0.8。5.2 概率平滑别让预测结果每秒跳三次上面的脚本里current_label 直接由当前窗口的 argmax 决定实际操作中会发现预测标签在窗口滑动的过程中来回跳。原因很简单手在动作过程中会经过另一个词的附近某几帧的窗口内容恰好对应了相似轨迹。解决办法是给概率加一个时间平均。维护一个长度为 5 的历史概率数组每次预测取这 5 次概率的平均值再取 argmax。这样做的效果是单个窗口的异常预测会被其余 4 个正常窗口稀释掉标签切换变得更像人的判断而不是帧级抖动。实现上不需要改模型加一小段就行prob_history [] def smoothed_prediction(prob): prob_history.append(prob) if len(prob_history) 5: prob_history.pop(0) avg np.mean(prob_history, axis0) return int(np.argmax(avg)), float(np.max(avg))逻辑说明prob_history 存最近 5 次 softmax 输出形状是 (5, num_classes)沿 axis0 求平均。平滑窗口越大越稳定但动作切换的反应变慢5 次约等于在 30 帧窗口上又叠加了 5 帧延迟。参数说明实时演示建议 3 到 5需要模型快速响应连续多个词时可以降到 3。5.3 模型导出与推理加速h5 转 tflite 与 predict 的调用开销如果答辩时要在没有 tensorflow 的机器上演示或者想让推理再快一档可以把 h5 模型转成 tflite。转换只需要几行import tensorflow as tf model tf.keras.models.load_model(hand_sign_model.h5) converter tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations [tf.lite.Optimize.DEFAULT] tflite_model converter.convert() with open(hand_sign_model.tflite, wb) as f: f.write(tflite_model)逻辑说明TFLiteConverter 把 keras 模型序列化成移动端友好的格式optimizations 设为 DEFAULT 会做量化压缩模型体积能减小将近四分之三推理速度提升明显。代价是部分算子量化后有轻微精度损失对 0.7 置信度阈值来说影响不大。参数说明如果需要绝对精度优先去掉 optimizations 那行转出来的模型不做量化精度无损但体积大、速度慢。tflite 推理用解释器跑接口与 keras 不同但输入输出结构一致。如果你是带着摄像头在答辩现场演示建议提前备一个 tflite 版本避免现场机器没装齐依赖导致翻车。6. 答辩前怎么证明模型真的有用独立测试集、混淆矩阵与结果可视化毕业设计答辩时老师不会满足于模型准确率 95%这一句话。他们关心的三个问题是数据是怎么来的、模型为什么选它、准确率数字是否可信。前两个前面已经解决最后一个要靠独立测试集和可视化来支撑。第一步是做一个完全独立于训练数据的测试集。训练时的随机划分是同一批录像的切分不能证明泛化能力。答辩前重新录一遍每个词每词 10 段保证录制环境、时间和训练数据不同把这批数据作为 final_test。拿着这个测试集去算准确率这个数字才是可以写进论文的。第二步是画混淆矩阵。手语识别里最容易出现的问题是某两个词总被互相认错比如动作开始段相似的词。混淆矩阵能一眼定位这一类问题让你在答辩时主动说出哪两个词容易混淆原因是动作前段相似可以在采集时增加动作幅度差异来缓解。这比任何准确率数字都有说服力。绘制用 sklearn 的 confusion_matrix 加 matplotlib 的 imshow输出一张对应的热力图。第三步是可视化模型到底看到了什么。把测试视频的关键点轨迹画出来逐帧显示 21 个点的手形同时把模型预测概率柱状图和当前窗口骨架图并排显示。这段可视化放在答辩现场比 PPT 里的架构图直观得多。实现上就是在推理脚本里把 landmark 坐标按原图比例映射回去用 cv2 画点和线段。我自己的习惯是答辩前一周专门做一次换环境测试换一个没去过的教室穿不同颜色的衣服用笔记本自带摄像头再跑一遍实时演示。如果某个词连续识别错三次立刻回去补数据或调整阈值。如果时间富余再做一个消融对比把 LSTM 换成一层全连接直接对 30×63 展平向量分类对比准确率差异这个实验能直观证明时序建模带来的提升是答辩中最容易出彩的一页。对比实验必须用同一份训练集和测试集只改模型结构。回到最初那句话手语识别这个方向真正难的不是模型而是数据的一致性和验证的严谨性。希望这篇笔记能帮你在答辩前少走几条弯路把时间花在真正加分的事情上。本文还有配套的精品资源点击获取
返回列表