基于傅里叶描述子与SVM的Python实时手势识别实战

简介:这是一份面向机器学习与计算机视觉学习者的手势识别完整项目源码包,围绕傅里叶算子在手势轮廓特征提取中的应用展开,适合课程设计、毕业设计及算法入门实践。包内共2000个文件,以txt说明与样本标注、png手势图像、m脚本、py源码及少量pyc、xml、docx文档为主,压缩包约142.52MB,样本库、训练模型与界面程序相对齐全。项目覆盖图像平滑、OTSU阈值肤色分割、八邻域轮廓搜索,以及傅里叶描述子和椭圆傅里叶描述子的提取与归一化,并用KNN、SVM完成训练对比,最后提供基于PyQt5的简易交互界面。已有13878人学习下载,读者可据此复现从采集图像到轮廓曲线、特征构造、模型训练与可视化演示的完整流程,并参考已有样本和脚本排查环境配置、参数调整与分类效果问题。

1. 摄像头前比个「OK」,标签就出来了

摄像头对着自己的手,比一个「OK」,屏幕上跳出 ok;换成握拳,标签翻成 fist。整套逻辑跑在一台没有独显的笔记本上,单帧处理不到 20 毫秒。这份基于傅里叶算子的手势识别完整源代码(Python 实现,附带样本库),把采集、预处理、形状特征提取到 SVM 分类的每一段都摊开写了,没有黑盒模型,也没有几百 MB 的权重文件。它适合三类人:做课程设计需要一个能讲清原理又不烧显卡的方案;想在树莓派一类的板子上跑实时识别;以及已经会用深度学习调包、但想弄明白「形状描述子」到底怎么把一条轮廓变成特征向量的开发者。读完你能把它跑起来,也能按自己的手势类别重新训一版。

2. 技术选型与工程架构:傅里叶描述子加 SVM 的完整链路

动手之前先把这套组合的账算清楚。手势识别这个题目,绝大多数人第一反应是上卷积网络,但卷积网络的门槛不在模型本身,在数据——每个类别没有几百上千张带变化的样本,训出来的东西很容易变成「只认训练时那个背景」。傅里叶描述子走的是另一条路:它不学纹理,只描述轮廓形状,把一条二维闭合曲线变换成一串频域系数,低频管整体形状,高频管指尖的细节。手部轮廓恰好是这类特征的理想对象,握拳、伸掌、比数字,差异全在轮廓的凹凸结构上。

2.1 为什么不用 CNN:小样本与算力账

把两种方案的工程量摆在一起看更直观。CNN 路线要做的是:收集几千张图、标注、数据增强、划分训练验证、调学习率、处理过拟合、导出模型、部署推理框架。FD + SVM 路线要做的是:采集几百帧、抠轮廓、算特征、训一个几十 KB 的模型、joblib 一 dump 就完事。前者精度上限高,后者在「类别少、背景可控、样本自己采」的场景里性价比高得多。

代价也很明确,得提前说清楚:傅里叶描述子只用形状,丢了颜色和纹理,所以它天然区分不了「同样是五指张开、但手掌朝向不同」这类手势;取幅度谱之后连镜像也分不出来,左手比的手势和右手比的可能归到同一类。如果你的需求是复杂手势、连续动作、大词汇量,这套方案不合适,别硬上。反过来,如果是五到十个静态手势、固定机位、室内光照,它能做到几百帧样本就出结果,而且每一维特征都可解释,出问题能查——这一点在做课程设计答辩或者给非算法同事讲原理时特别值钱。

SVM 在这里的角色是「小样本分类器」。特征只有二十来维,样本量几百到几千,这个比例下线性核和 RBF 核都能收敛得很稳,而且 SVM 的决策边界由支持向量决定,样本少的时候比随机森林更不容易被噪声带偏。常见的做法是 RBF 核加网格搜索,把 C 和 gamma 搜一遍,再用宏平均 F1 选最优,避免类别不平衡时被多数类刷分。

2.2 从一帧画面到分类标签的四段流水线

整条链路可以切成四段,每段职责单一,方便单独替换:

  1. 采集:从摄像头或视频文件读帧,同时把带标签的原始帧按类别存盘,这是样本库的源头。
  2. 预处理:颜色空间转换 → 肤色掩膜 → 形态学修补 → 取最大外轮廓,输出一条点序列。
  3. 特征提取:轮廓等弧长重采样到固定点数 → 复数编码 → FFT → 取低频幅度谱并归一化,输出定长向量。
  4. 分类:标准化 + SVM 预测,实时场景再加一层滑窗投票压抖动。

这四段之间用 numpy 数组衔接,没有隐式的全局状态,所以你可以只换第三段(比如改用 Hu 矩),后面训练脚本一行不改。这也是我倾向于把预处理和特征提取拆成两个文件的原因——调试的时候能单独把中间结果存成图片看,比在整条链路里打断点快得多。

2.3 样本库与目录结构

源包里的目录组织大致是这样,训练脚本和识别脚本都按这个约定找文件:

gesture-fourier/ ├── data/ │ ├── raw/ # 原始采集帧,一个类别一个子目录 │ │ ├── fist/ │ │ ├── palm/ │ │ └── ok/ │ └── features/ # 提取后的特征矩阵与标签 │ ├── X.npy │ └── y.npy ├── src/ │ ├── config.py # 阈值、路径、类别名集中放这里 │ ├── capture.py # 采集脚本,按空格存帧 │ ├── preprocess.py # 肤色分割与轮廓提取 │ ├── fourier_descriptor.py # 重采样与 FD 计算 │ ├── build_dataset.py # 批量提特征,落 X.npy / y.npy │ ├── train_svm.py # 网格搜索与模型导出 │ └── recognize.py # 实时识别主循环 ├── models/ │ ├── svm.pkl │ └── scaler.pkl └── requirements.txt

依赖不多,装起来不费劲:

包用途备注
opencv-python读帧、颜色空间、轮廓、形态学4.x 均可
numpy重采样、FFT、矩阵运算建议 1.20 以上
scikit-learnSVM、标准化、网格搜索提供 Pipeline 与 joblib
joblib模型序列化sklearn 已自带依赖

config.py里集中放阈值这件事看着不起眼,但它是后期调参的救命稻草。肤色阈值、最小轮廓面积、重采样点数、描述子维数,全部提成常量,在识别脚本里用同一个 config,训练和推理才不会出现「训练用 128 点、推理用 64 点」这种对不上的情况。我见过太多项目把参数散落在各个文件里,改一个忘一个,排查起来非常痛苦。

3. 特征提取链路:从肤色掩膜到定长傅里叶描述子

这一段是整份代码的核心,也是最容易翻车的地方。输入是一帧 BGR 图像,输出是一个 20 维的 float32 向量。中间有四步,每一步都能单独拿出来验证。

3.1 肤色分割与掩膜生成

单纯用 HSV 做肤色分割在室内灯光下很容易碎裂,代码里用的是 YCrCb 加 HSV 双空间求交集的办法,两个空间都认为是肤色的像素才保留:

import cv2 import numpy as np def hand_mask(bgr, hsv_lower=(0, 30, 60), hsv_upper=(25, 180, 255)): """双颜色空间求交,返回单通道 0/255 掩膜""" ycrcb = cv2.cvtColor(bgr, cv2.COLOR_BGR2YCrCb) hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) # YCrCb 的经典肤色区间:Cr 133~173,Cb 77~127 m1 = cv2.inRange(ycrcb, np.array([0, 133, 77]), np.array([255, 173, 127])) m2 = cv2.inRange(hsv, np.array(hsv_lower), np.array(hsv_upper)) return cv2.bitwise_and(m1, m2)

逻辑上,YCrCb 的 Cr 通道对肤色响应强、对亮度不敏感,是分割的主力;HSV 的 H 通道负责把偏红偏黄的干扰(木质桌面、纸箱)挡掉。两者求交是「宁可少留,不可多留」,因为后面的最大轮廓筛选会丢掉零碎区域,但多出来的一大块非手区域是丢不掉的。

几个参数值得说:Cr 的上下界 133 和 173 是通用值,实际用的时候最好拿自己的手在镜头前跑一遍,看掩膜覆盖得全不全,偏了就上下挪 5 到 10。HSV 的 S 下界 30 用来过滤灰白色(墙壁、白纸),如果你的背景是深色桌面,可以放到 20;V 下界 60 用来挡阴影,光线暗的环境要往下调。这些都写进 config,别硬编码在函数里。

3.2 最大轮廓筛选与形态学修补

原始掩膜出来之后一定是有毛刺和空洞的,直接取轮廓会得到一条锯齿状的曲线,傅里叶高频分量会被噪声主导。所以中间要插一步形态学处理:

def clean_and_extract(bgr, min_area=3000): """掩膜清理 + 最大外轮廓提取,失败返回 None""" mask = hand_mask(bgr) # 椭圆核比矩形核更贴合手部边缘,核大小随分辨率调整 k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k) # 去掉零星噪点 mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k) # 补上掌心的空洞 mask = cv2.medianBlur(mask, 5) # 抹平边缘毛刺 cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) if not cnts: return None, mask c = max(cnts, key=cv2.contourArea) # 面积门槛挡住「画面里没人手」的帧,避免把噪点当手 if cv2.contourArea(c) < min_area: return None, mask return c, mask

MORPH_OPEN先腐蚀后膨胀,专治孤立噪点;MORPH_CLOSE先膨胀后腐蚀,专治掌心因为反光造成的空洞。顺序不能反,反过来会把噪点也一起闭合进去。medianBlur用中值而不是高斯,是因为中值滤波对边缘的保留更好,高斯会把指尖磨圆,直接影响描述子里对应指尖的高频分量。

CHAIN_APPROX_NONE表示存储轮廓上的每一个像素点,不做压缩。这是有意的:后面的等弧长重采样需要足够密的点才能保证插值精度,如果用了CHAIN_APPROX_SIMPLE,直线段会被压成两个端点,重采样出来的形状就不对了。

min_area这个门槛在 640×480 分辨率下取 3000 左右比较合适,手掌在画面里的占比大概三成。如果用户坐得远,手只占一小块,这个值要往下调,但同时误检率会上升,通常的做法是反过来——固定一个 ROI 方框,让用户把手放进去,从工程上消灭这个问题。

3.3 轮廓重采样到定长点集

findContours给出的点数是浮动的:手离镜头近一点,轮廓周长变长,点数就有七八百;远一点可能只有三百。傅里叶变换要求输入序列长度固定,所以必须先重采样。这里用的是等弧长插值,沿轮廓周长均匀取 N 个点:

def resample_contour(contour, num_points=128): """沿轮廓周长等弧长采样,返回 (num_points, 2) 的 float64 数组""" pts = contour.reshape(-1, 2).astype(np.float64) pts = np.vstack([pts, pts[:1]]) # 首尾相接,闭合 seg = np.sqrt(((pts[1:] - pts[:-1]) ** 2).sum(axis=1)) cum = np.concatenate([[0.0], np.cumsum(seg)]) total = cum[-1] # 目标采样位置(不包含终点,避免与起点重合) s = np.linspace(0.0, total, num_points, endpoint=False) # 定位每个采样点落在哪一段,并在段内线性插值 idx = np.clip(np.searchsorted(cum, s, side='right') - 1, 0, len(seg) - 1) t = (s - cum[idx]) / (seg[idx] + 1e-9) out = pts[idx] + t[:, None] * (pts[idx + 1] - pts[idx]) return out

num_points取 128 是个经验值。太小(比如 32)会把手指之间的凹槽抹平,不同手势区分不开;太大(512 以上)对精度没有实质提升,反而让 FFT 变慢,而且高频段全是噪声。128 点对应到 640×480 的画面,指尖的圆角大概能占到 3 到 5 个采样点,够用了。

这段代码全部向量化,没有 Python 层循环,128 点的重采样在普通 i5 上大概几十微秒。searchsorted用的是side='right',配合减一,保证落在段起点时取到正确的区间;分母加1e-9是防止退化轮廓(比如手完全静止被压成一条线)出现除零。

3.4 复数编码、FFT 与频谱归一化

真正把轮廓变成特征的一步。把重采样后的点看成复平面上的序列 z = x + iy,做 FFT,然后取低频部分的幅度谱:

def fourier_descriptor(contour, num_points=128, num_coeffs=20): """轮廓 -> 定长傅里叶描述子,返回 (num_coeffs,) 的 float32""" pts = resample_contour(contour, num_points) z = pts[:, 0] + 1j * pts[:, 1] z = z - z.mean() # 去质心:平移不变 F = np.fft.fft(z) # 丢掉 DC 分量 F[0](只含位置信息),取前 num_coeffs 个低频 mag = np.abs(F[1:num_coeffs + 1]) mag = mag / (mag[0] + 1e-8) # 以基频归一:尺度不变 return mag.astype(np.float32)

三处设计各对应一个不变性。去质心处理平移:手在画面左边还是右边,减掉均值之后序列完全一样。取幅度谱丢相位同时解决旋转和起点问题:轮廓旋转 θ 等价于复数序列乘以 e^{iθ},起始点变化等价于乘以 e^{iωk},两种操作都只改相位不改幅度,所以幅度谱天然免疫。除以基频处理尺度:手离镜头远近不同导致轮廓整体放大 k 倍,频谱所有系数同比例放大,一除就消掉了。

num_coeffs=20是精度和维数的折中。前 20 个低频系数基本覆盖了手部轮廓的整体形状和主要凸起,再往后就是指尖圆角级别的细节,对抗噪声没好处。真要调,可以对比 12、20、32 三档在验证集上的表现——维数上去之后 SVM 的训练时间会涨,但在几百个样本的规模下差别不明显,主要还是看泛化。

拿到的 20 维向量就是样本库里的「一行」。批量脚本遍历data/raw/下每个类别子目录,对每张图跑一遍上面的链路,提不出来的(掩膜面积不够)直接跳过并计数,最后统一存成X.npy和y.npy。这里有个细节值得做:把跳过的文件名打出来,如果某一类跳过的比例明显高于其他类,说明那一类的采集条件有问题,得回去重采,而不是硬着头皮训。

4. SVM 训练与实时识别:模型怎么训、怎么存、怎么跑

特征有了,剩下的就是分类器。这一段代码量不大,但工程上有几个点不注意,模型上线就会失效。

4.1 特征矩阵构建与标准化

20 维特征里,第一维恒为 1(归一化后基频除以自己),其余各维的量级从前到后递减,可能差两三个数量级。SVM 用的是欧氏距离,量级大的维度会主导核函数,所以标准化必须做,而且必须和模型绑在一起:

from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC pipe = Pipeline([ ('scaler', StandardScaler()), # 按列减均值除标准差 ('svm', SVC(kernel='rbf', probability=True, # 需要输出置信度时打开 class_weight='balanced')) # 类别样本数不均衡时自动加权 ])

用Pipeline而不是手动fit_transform再训练,关键原因在序列化:只要 dump 这一个对象,标准化参数和 SVM 参数就一起存下来了,推理时不会再出现「忘了标准化」的低级错误。class_weight='balanced'按各类样本数的倒数加权,采集时拳头比划得多、ok 比划得少,这个参数能把少数类拉回来。probability=True会额外做一次内部交叉验证来拟合概率,训练慢一些,如果你只需要标签,关掉它。

4.2 网格搜索与模型序列化

RBF 核有两个关键超参:C 控制对错分的惩罚,gamma 控制单个样本的影响半径。用手写猜测纯属浪费时间,直接网格搜索:

import numpy as np, joblib from sklearn.model_selection import GridSearchCV, train_test_split X = np.load('data/features/X.npy') y = np.load('data/features/y.npy') X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42) param_grid = { 'svm__C': [0.1, 1, 10, 100], 'svm__gamma': ['scale', 0.01, 0.05, 0.1], } grid = GridSearchCV(pipe, param_grid, cv=5, scoring='f1_macro', n_jobs=-1) grid.fit(X_tr, y_tr) print('best:', grid.best_params_, 'cv f1:', grid.best_score_) print('holdout acc:', grid.score(X_te, y_te)) joblib.dump(grid.best_estimator_, 'models/svm.pkl')

参数名前面的svm__是 Pipeline 的语法,指「流水线里名字叫 svm 的那一步的某个参数」。scoring='f1_macro'而不是默认的准确率,是因为宏平均 F1 对每个类别一视同仁,样本少的类别拉胯会立刻反映出来;用准确率的话,一个占比 60% 的类别就能把分数撑得很好看。stratify=y保证训练集和测试集里各类别比例一致,样本量小的时候这个尤其重要,否则切分一次可能某个类在测试集里一个都没有。

落盘用grid.best_estimator_而不是grid.best_params_,因为前者是训练好的完整 Pipeline,后者只是参数字典,还得自己重建。模型文件通常只有几十 KB,跟着代码一起走完全没问题。

4.3 摄像头循环里的滑窗投票

单帧预测会有抖动,尤其是手在动的过程中轮廓不稳定,标签会来回跳。工业上的常规做法是加一个长度固定的队列做多数投票:

import collections, joblib, cv2 import numpy as np from preprocess import clean_and_extract from fourier_descriptor import fourier_descriptor clf = joblib.load('models/svm.pkl') buf = collections.deque(maxlen=7) cap = cv2.VideoCapture(0) while True: ok, frame = cap.read() if not ok: break contour, mask = clean_and_extract(frame) label = 'no-hand' if contour is not None: feat = fourier_descriptor(contour).reshape(1, -1) pred = clf.predict(feat)[0] buf.append(pred) if len(buf) >= 5: # 攒够 5 帧才开始输出 label = collections.Counter(buf).most_common(1)[0][0] cv2.putText(frame, label, (20, 50), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 0), 2) cv2.imshow('gesture', frame) if cv2.waitKey(1) & 0xFF == 27: # ESC 退出 break cap.release() cv2.destroyAllWindows()

deque(maxlen=7)固定长度,满了自动从左边弹,不用手动维护。攒够 5 帧才输出是为了避开刚把手伸进画面的那几帧——那时候轮廓还没稳定,预测基本是随机的。投票窗口 7 帧对应到 30fps 的摄像头大概 0.23 秒,延迟可以接受;如果觉得反应慢,缩到 5 帧,但抖动会回来一点。

clf.predict直接吃(1, 20)的数组,因为它内部会自动调用 scaler,这也是前面强调整条流水线一起存的原因。如果哪天想换成「置信度低于阈值就不输出」,把predict改成predict_proba,取max和对应类别即可,阈值设在 0.6 附近通常比较稳。

5. 常见问题排查:五类翻车的现象、成因与解决

这套代码跑通不难,跑稳不容易。下面五条是实际调试时最常撞上的,按现象、原因、解决三段写,遇到问题可以直接对号入座。

5.1 预处理阶段:轮廓抓错、抖动、粘连

第一条:轮廓跳到脸上。现象是掩膜里同时有手和脸,max(cnts, key=cv2.contourArea)选了面积更大的人脸,输出标签乱跳。原因很直接:肤色分割不区分身体部位,人脸和手的颜色分布高度重叠,面积还常常更大。解决办法是在画面上固定一个 ROI 方框,只在框内做分割——让用户把手伸进框里,人脸自然被排除。这一步的代价是要多一次数组切片,收益是把「背景里有其他肤色区域」这个最难缠的干扰从源头切掉了。如果不想加 ROI,退一步的做法是给轮廓加长宽比约束(手的轮廓通常比脸扁),但这属于打补丁,稳定性不如 ROI。

第二条:握拳时轮廓几乎消失。现象是比划拳头的时候掩膜碎成几块,min_area筛不过,输出 no-hand。原因是握拳时手部暴露的皮肤面积本来就小,加上指缝间的阴影让 Cr 通道的响应掉出阈值区间。解决有两步:一是把MORPH_CLOSE的核从 7 加大到 11,把指缝间的空洞补上;二是把min_area从 3000 降到 1200 左右。但降门槛会带来误检,所以更稳的做法是采集阶段就统一手离镜头的距离,让所有类别的轮廓面积落在同一个区间里。

第三条:轮廓边缘像锯齿,识别率上不去。现象是存下来的掩膜边缘毛刺很多,算出来的高频系数每次都不一样,同一姿势连续几帧预测出不同标签。原因通常是光照不均,局部过曝导致掩膜在该区域整片丢失。解决顺序是:先确认是不是过曝(把 mask 和原图并排显示,一眼就能看出来);是的话在采集端加一盏侧光或者避开顶灯直射;软件侧可以在medianBlur之后再做一次MORPH_CLOSE,核大小取 5。有个玄学的地方是摄像头自动曝光,画面亮度会缓慢漂移,建议在VideoCapture之后手动把曝光锁死(cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25)),这一步做了之后掩膜稳定性会有肉眼可见的提升。

5.2 训练与部署阶段:准确率虚高与现场失灵

第四条:交叉验证 0.99,换个人用掉到 0.4。这是最典型的数据泄漏。现象是训练报告漂亮得不像话,实际演示时频繁出错。原因几乎总是同一段视频的相邻帧被随机切分到了训练集和测试集——相邻帧的内容差异极小,模型相当于在背答案。train_test_split的random_state只能保证切分可复现,挡不住泄漏。正确做法是按「录制段」分组划分:采集时每录一段就记一个 session id,切分时保证同一个 session 的帧只出现在一边,或者直接用GroupKFold传groups参数。改完之后分数通常会掉一大截,但那才是真实水平。

第五条:模型换了台机器就失灵。现象是开发机上好好的,拷到另一台电脑上所有预测都错。两种可能,一种是文件路径写死导致加载了旧模型,另一种更隐蔽——训练时用了Pipeline,但推理脚本里手动joblib.load了单独的svm.pkl而漏掉scaler.pkl,或者反过来把原始特征直接喂给了未标准化的模型。诊断方法是把推理脚本里的特征向量打印出来,和训练时保存的X.npy里同类样本比对一下量级,对不上就是标准化没走。根因还是序列化策略,把整个Pipeline存成一个文件这个坑就填上了。

顺带提一个不算 bug 但很坑的点:镜像手势。因为取的是幅度谱,左手比「OK」和右手比「OK」的频谱完全一样,模型分不出来。如果你做的场景需要区分左右手,得在特征里额外拼一维带符号的信息(比如轮廓质心相对画面中线的偏移),或者干脆保留前几个系数的相位。这件事在做需求设计时就得想清楚,训完再发现就只能重来。

6. 二次开发的几个改造口子:换分类器、加类别、做工程化

代码跑通之后,多数人下一步都会想改点什么。三个方向最实用。

换分类器最省事,因为特征和分类是解耦的。把SVC换成RandomForestClassifier(n_estimators=200)或者KNeighborsClassifier(n_neighbors=5),其他代码一行不动,直接对比混淆矩阵。样本量在几百的时候,KNN 有时反而比 SVM 稳,因为它对特征空间的局部结构更敏感;样本上千之后 SVM 的优势才明显。换的时候注意 KNN 不需要class_weight,但更需要标准化,别把 Pipeline 拆了。

加新类别的正经流程是四步:在data/raw/下建新目录 → 用采集脚本录 100 到 200 帧,尽量覆盖不同距离和角度 → 重跑build_dataset.py生成新的X.npy(注意要把旧类别一起重算,别只算新的)→ 重跑train_svm.py。第三步最容易被跳过,有人图省事手动往X.npy里 append,结果特征顺序和标签顺序对不上,训练出来的模型全是乱的。这种血泪经验吃过一次就记住了。

工程化方面,我一般会把整条链路封成一个类,构造函数里加载模型,predict(frame)返回标签和置信度,内部维护投票队列。这样嵌到 GUI 或者网络服务里都只是多写几行胶水代码。配置全部走config.py,别在函数签名里塞默认值。如果目标平台没有 sklearn(比如某些嵌入式环境),可以考虑把训练好的 SVM 参数导出来自己实现决策函数,RBF 核的推理就是几十行 numpy,移植成本不高。

验证改造效果别只看准确率。我现在的习惯是每次改完都强制走一遍「分组交叉验证 + 混淆矩阵 + 录一段没参与训练的现场视频跑一遍」这三步,前两步看模型本身,第三步看真实环境。从那以后我每次动特征维度或者换分类器,都会先把这三步跑完再提交,省下来的返工时间远比写验证脚本的时间多。希望帮到你。

本文还有配套的精品资源,点击获取