简介
苹果官方为我们提供了简单手势的识别器,但对于图形手势,例如五角星、三角形等的识别,就需要自己实现了。通过识别这些手势,可以去执行特定的操作,或是输入公式、释放魔法等,可以为App增光添彩。
下载与使用
该框架已经上传到github,点击这里前去下载,欢迎Star!
有关该框架的使用在github上已经写明,这里不再赘述,本文主要介绍的是图形手势识别的实现原理与框架的结构。
框架的结构
一个图形手势是一条曲线,可以用采样点来描述,存储采样点的类为SGGesturePoint
,使用它替代CGPoint
,更符合面向对象的操作规范。
采样点的集合为SGGestureSet
,它用于记录所有采样点以及手势的名称,以及标准化以后的手势向量。
手势向量使用SGGestureVector
存储,向量通过将所有采样点的(x,y)坐标依次插入并标准化得来,手势向量用于进行余弦相似度计算。
用于标准化集合、生成向量、保存、加载与识别手势的类为SGGestureManager
,它是一个单例对象,正常使用中只需要关心manager和set两个对象,其他对象由manager负责管理。
手势采样的过程
1.采样
Demo中给出的是使用UIPanGestureRecognizer完成的采样,每个采样点都是一个CGPoint
,将其包装秤NSValue
并且存储在数组中。
2.生成集合
使用SGGestureSet
的gestureSetWithName:points:方法,传入手势的名称与采样点(NSValue数组),即可使用采样点初始化一个集合。
3.重新采样
将集合中的所有采样点构成的图形看作折线,根据所需要的采样密度确定采样间距interval,并在原曲线上生成均匀分布的采样点,生成采样点的代码如下,代码后将结合图例进行讲解。
// to resample the curve, calculate the length of the curve
SGGestureSet *tempSet = *set;
double sumLength = 0;
for (int i = 1; i < tempSet.countPoints; i++) {
SGGesturePoint *pt1 = [tempSet pointAtIndex:i];
SGGesturePoint *pt2 = [tempSet pointAtIndex:i - 1];
sumLength += [pt1 distanceTo:pt2];
}
// resample with sample uniform distributed points
SGGestureSet *resampleSet = [SGGestureSet gestureSetWithName:tempSet.name];
double Interval = sumLength / self.samplePointCount;
double D = 0;
SGGesturePoint *p1 = [tempSet pointAtIndex:0];
[resampleSet addGesturePoint:p1];
for (int i = 1; i < tempSet.countPoints;) {
SGGesturePoint *p2 = [tempSet pointAtIndex:i];
double d = [p1 distanceTo:p2];
if ((D + d) >= Interval) {
double k = (Interval - D ) / d;
double x = p1.x + k * (p2.x - p1.x);
double y = p1.y + k * (p2.y - p1.y);
SGGesturePoint *p = [SGGesturePoint gesturePointWithCGPoint:CGPointMake(x, y)];
[resampleSet addGesturePoint:p];
D = 0;
p1 = p;
}else{
D += d;
p1 = p2;
i++;
}
}
其中D用于折现拐点后确定下一采样点的距离,d为原集合中的相邻采样点间距,下图是某图形手势的局部折线图。
第一次进入循环时,P1为原集合的第一个采样点(也是重新采样集合的第一个点),P2为原集合的第二个采样点,他们之间的距离P大于重新采样的采样点间距interval,这时D=0,D+d=d>interval,因此进入if分支。
接下来根据interval与d的比例关系求出x、y的步进值,从而得到下一个重新采样点的坐标点,并且这个点作为新的P1。
以此类推,由于折线段的长度比interval大的多,因此能够分布许多新采样点,直到P1足够接近P2,使得interval>d,如下图所示。
这时下一个采样点应该落在下一个折线段上,并且为了保证均匀分布,下一个采样点距离折线段拐点的距离应该减去当前P1到P2的距离,这就是D的作用了。下图说明了这个计算的目的。
这时候会进入else分支,将P1更新为P2坐标,并且将d累加到D中,需要注意的是,下一个P2的坐标并不是原集合中的点,而是根据D计算出的点,因此应该跳过原集合中的下一个点,这就是i++的作用。如果曲线有足够多的短折线段,则会不断的进入else分支,一直累加d,直到满足新采样点间距,也就是D+d>=interval,才生成一个新的采样点,本图中的情况仅仅计算了一次D就进入了下一条比较长的折线段,这时候清空D,开始在折线段上分布采样点,如下图所示。
经过多次这样的运算,就可以完成均匀分布的重新采样了,之后使用的是重新采样点的集合。
4.曲线位置的标准化
将集合中的x、y分别求平均值,得到曲线的重心,根据重心坐标将曲线移动到坐标原点,得到标准位置的曲线。
5.曲线尺寸的标准化
根据曲线的外接矩形与标准尺寸将曲线上的每个点进行比例运算,即可得到缩放到标准尺寸的曲线。
6.曲线转角的标准化
根据曲线上的第一个采样点与中心的连线的角度对曲线进行标准化,设当前角度为iAngle,目标角度为r,则如下图所示经过坐标变换将曲线旋转到红色位置。
7.生成向量
为了进行后续运算,需要将二元采样点集化为一元集合,也可以看做多维向量,方法是依次将采样点的x、y坐标插入一元集合,并对向量进行标准化,每个向量代表一个手势,可用于后续的比较运算。
手势识别的过程
对于需要识别的手势,先经过上面的运算得到手势向量,然后将这个手势向量与手势库中的向量逐一进行余弦相似度的运算,余弦相似度比较的是向量的夹角,夹角越小则越相似,根据一定的阈值来筛选出符合条件的所有手势,并在遍历结束后取最优(运算结果最小)的作为匹配结果。
余弦相似度的计算代码如下:
- (double)cosDistanceWithVector1:(SGGestureVector *)vec1 vector2:(SGGestureVector *)vec2 {
double a = 0;
double b = 0;
for (int i = 0; i <= vec1.length - 1 && i <= vec2.length - 1; i+=2) {
a += [vec1 doubleAtIndex:i] * [vec2 doubleAtIndex:i] + [vec1 doubleAtIndex:i + 1] * [vec2 doubleAtIndex:i + 1];
b += [vec1 doubleAtIndex:i] * [vec2 doubleAtIndex:i + 1] - [vec1 doubleAtIndex:i + 1] * [vec2 doubleAtIndex:i];
}
double angle = atan(b / a);
return acos(a * cos(angle) + b * sin(angle));
}
识别一个手势的代码如下,先标准化手势集合并得到向量,然后在手势库中筛选,最后选择最优结果。
- (NSString *)recognizeGestureSet:(SGGestureSet *)set {
[self standardizeSet:&set];
SGGestureVector *vec1 = [set getVector];
SGGestureSet *bestSet = nil;
double minD = CGFLOAT_MAX;
for (int i = 0; i < self.gestureSets.count; i++) {
SGGestureSet *libSet = self.gestureSets[i];
SGGestureVector *vec2 = [libSet getVector];
double D = [self cosDistanceWithVector1:vec1 vector2:vec2];
if(D <= self.threshold && D < minD){
minD = D;
bestSet = libSet;
}
}
return bestSet.name;
}
手势的存取
以上介绍的每一个与存储有关的类都遵循NSCoding协议,将每一个标准化的SGGestureSet
存入到数组中,并将数组利用NSKeyedArchiver
归档存储到磁盘,需要读取时再通过NSKeyedUnarchiver
反归档即可。