这篇文章是Andrew Davison博士发布的有关自然用户界面(NUI)系列的一部分,内容涉及使用JavaCV从网络摄像头视频提要中检测手。
注意:可以从http://fivedots.coe.psu.ac.th/~ad/jg/nui055/下载本章的所有源代码。
第5章的彩色斑点检测代码(可从http://fivedots.coe.psu.ac.th/~ad/jg/nui05/获得 )可以用作其他形状分析器的基础,我将在此处进行说明。通过扩展它来检测手和手指。 在图1中,我的左手戴着黑手套。 我的Handy应用程序尝试查找并标记拇指,食指,中指,无名指和小指。 在指尖和手的重心(COG)之间绘制黄线。
我使用了第5章的HSVSelector应用程序来确定黑手套的合适HSV范围。 在执行图2所示的步骤之前,Handy会加载这些范围,以获取手部的轮廓,其COG和相对于水平面的方向。
图2中的各个阶段几乎与第5章第4.1节中的ColorRectDetector.findRect()方法执行的阶段相同。但是,Handy继续进行处理,使用凸包和凸凹缺陷来定位并标记手中的指尖轮廓。 这些附加步骤如图3所示。
船体和缺陷是通过标准的OpenCV操作从轮廓获得的,我将在下面进行解释。 但是,命名手指的最后一步使用了一种颇为怪异的策略,该策略假定轮廓的缺陷是针对伸出的左手。 拇指和食指基于它们相对于COG的角度位置来定位,而其他手指则根据它们相对于那些手指的位置来标识。 这个过程非常脆弱,并且很容易混淆,如图4所示。
但是,该技术相当可靠,通常至少可以识别拇指和食指,而与手的方向无关,这对于基本的手势处理来说应该足够了。 但是,该应用程序无法识别手势,希望它将成为下一章的主题。
Handy的类图如图5所示,仅列出了公共方法。
Handy的顶级与第5章中的BlobsDrumming应用程序的顶级并行(例如,参见第5章的图11),其中Handy类管理JFrame和HandPanel,显示带注释的网络摄像头图像。 图2和3总结的图像分析由HandDetector类执行,该类通过调用update()传递给当前的网络摄像头快照。 当HandPanel调用HandDetector.draw()时,它将绘制当前标记的指尖,COG和连接线。
1.分析网络摄像头图像
update()方法实质上是实现图2和图3的一系列调用。
// globals
private static final int IMG_SCALE = 2;
// scaling applied to webcam image
// HSV ranges defining the glove color
private int hueLower, hueUpper, satLower, satUpper,
briLower, briUpper;
// OpenCV elements
private IplImage hsvImg; // HSV version of webcam image
private IplImage imgThreshed; // threshold for HSV settings
// hand details
private Point cogPt; // center of gravity (COG) of contour
private int contourAxisAngle;
// contour's main axis angle relative to the horiz (in degrees)
private ArrayList fingerTips;
public void update(BufferedImage im)
{
BufferedImage scaleIm = scaleImage(im, IMG_SCALE);
// reduce the size of the image to make processing faster
// convert image format to HSV
cvCvtColor(IplImage.createFrom(scaleIm), hsvImg, CV_BGR2HSV);
// threshold image using loaded HSV settings for user's glove
cvInRangeS(hsvImg, cvScalar(hueLower, satLower, briLower, 0),
cvScalar(hueUpper, satUpper, briUpper, 0),
imgThreshed);
cvMorphologyEx(imgThreshed, imgThreshed, null, null,
CV_MOP_OPEN, 1);
// erosion followed by dilation on the image to remove
// specks of white while retaining the image size
CvSeq bigContour = findBiggestContour(imgThreshed);
if (bigContour == null)
return;
extractContourInfo(bigContour, IMG_SCALE);
// find the COG and angle to horizontal of the contour
findFingerTips(bigContour, IMG_SCALE);
// detect the fingertips positions in the contour
nameFingers(cogPt, contourAxisAngle, fingerTips);
} // end of update()
update()首先缩放提供的网络摄像头图像以提高处理速度。 然后,它将图片转换为HSV格式,以便可以使用黑手套的HSV范围生成阈值图像。 这对应于图2的第一行,尽管实际上将阈值渲染为黑色背景上的白色像素。
减去小斑点的阈值传递给findBiggestContour(); 在随后的处理阶段中,假定所得轮廓是用户的手。 extractContourInfo()分析轮廓以找到手的重心(COG)及其相对于水平面的方向,这些重心存储在cogPt和ContourAxisAngle全局变量中。 extractContourInfo()的完成对应于图2的末尾。
findFingerTips()方法将凸包包裹在轮廓周围,以识别形状的缺陷(图3的顶行),我们假设这是手的手指。 经过少量过滤以减少缺陷数量之后,其余缺陷将被视为指尖坐标,并存储在全局fingerTips列表中。
nameFingers()标记手指(假设拇指和食指在手的左侧),完成图3的阶段。
1.1找到最大的轮廓
findBiggestContour()使用OpenCV函数cvFindContours()创建轮廓列表。 对于我的二进制阈值图像,轮廓是白色像素的区域(或斑点)。 每个斑点由一个边界框近似,并且选择并返回与最大框相对应的轮廓。
// globals
private static final float SMALLEST_AREA = 600.0f;
// ignore smaller contour areas
private CvMemStorage contourStorage;
private CvSeq findBiggestContour(IplImage imgThreshed)
{
CvSeq bigContour = null;
// generate all the contours in the threshold image as a list
CvSeq contours = new CvSeq(null);
cvFindContours(imgThreshed, contourStorage, contours,
Loader.sizeof(CvContour.class),
CV_RETR_LIST, CV_CHAIN_APPROX_SIMPLE);
// find the largest contour in the list based on bounded box size
float maxArea = SMALLEST_AREA;
CvBox2D maxBox = null;
while (contours != null && !contours.isNull()) {
if (contours.elem_size() > 0) {
CvBox2D box = cvMinAreaRect2(contours, contourStorage);
if (box != null) {
CvSize2D32f size = box.size();
float area = size.width() * size.height();
if (area > maxArea) {
maxArea = area;
bigContour = contours;
}
}
}
contours = contours.h_next();
}
return bigContour;
} // end of findBiggestContour()
cvFindContours()可以返回以不同类型的数据结构收集在一起的不同类型的轮廓。 我生成最简单的轮廓,将它们存储在线性列表中,可以使用while循环进行搜索。
经过一些试验,我在600平方像素的有边界框上放置了一个下限,以滤除围绕图像噪点的小框。 这意味着,如果findBiggestContour()找不到足够大的框,则可能返回null。
1.2计算COG和水平角
图2所示的下一步是通过调用extractContourInfo()查找COG和与手部轮廓水平线的夹角。 在此,HandDetector中的代码从ColorRectDetector.findRect()在第5章中进行的分析开始成为公司的一部分。在该章的4.2节中,利用轮廓周围的包围盒获取中心和方向。 这是足够的,因为基础形状是矩形卡片,因此轮廓和框几乎相同。 但是,手周围的边界框可能很容易产生与手本身完全不同的COG或角度。 在这种情况下,有必要利用力矩直接分析手部轮廓而不是边界框。
我在第3章中使用了空间矩来查找二进制图像的COG。 可以将相同的技术应用于轮廓以找到其中心(或质心)。 我还可以计算二阶混合矩,它提供了有关质心周围像素散布的信息。 可以组合二阶矩以返回轮廓的主轴相对于x轴的方向(或角度)。
回顾第三章的OpenCV矩符号,m()矩函数定义为:
该函数带有两个参数p和q,它们用作x和y的幂。 I()函数是由像素的(x,y)坐标定义的像素的强度。 n是组成形状的像素数。
如果考虑图6中的轮廓,则θ是其主轴线与水平面的角度,+ y轴指向下方。
就m()函数而言,可以证明:
如下所示的extractContourInfo()方法使用空间矩获取轮廓的质心,并使用cvGetCentralMoment()根据上述公式计算主轴角; 这些结果存储在全局变量cogPt和ContourAxisAngle中,以备后用。
// globals
private Point cogPt; // center of gravity (COG) of contour
private int contourAxisAngle;
// contour's main axis angle relative to horizontal (in degrees)
private ArrayList fingerTips;
private void extractContourInfo(CvSeq bigContour, int scale)
{
CvMoments moments = new CvMoments();
cvMoments(bigContour, moments, 1);
// center of gravity
double m00 = cvGetSpatialMoment(moments, 0, 0) ;
double m10 = cvGetSpatialMoment(moments, 1, 0) ;
double m01 = cvGetSpatialMoment(moments, 0, 1);
if (m00 != 0) { // calculate center
int xCenter = (int) Math.round(m10/m00)*scale;
int yCenter = (int) Math.round(m01/m00)*scale;
cogPt.setLocation(xCenter, yCenter);
}
double m11 = cvGetCentralMoment(moments, 1, 1);
double m20 = cvGetCentralMoment(moments, 2, 0);
double m02 = cvGetCentralMoment(moments, 0, 2);
contourAxisAngle = calculateTilt(m11, m20, m02);
// deal with hand contour pointing downwards
/* uses fingertips information generated on the last update of
the hand, so will be out-of-date */
if (fingerTips.size() > 0) {
int yTotal = 0;
for(Point pt : fingerTips)
yTotal += pt.y;
int avgYFinger = yTotal/fingerTips.size();
if (avgYFinger > cogPt.y) // fingers below COG
contourAxisAngle += 180;
}
contourAxisAngle = 180 - contourAxisAngle;
/* this makes the angle relative to a positive y-axis that
runs up the screen */
} // end of extractContourInfo()
private int calculateTilt(double m11, double m20, double m02)
{
double diff = m20 - m02;
if (diff == 0) {
if (m11 == 0)
return 0;
else if (m11 > 0)
return 45;
else // m11 < 0
return -45;
}
double theta = 0.5 * Math.atan2(2*m11, diff);
int tilt = (int) Math.round( Math.toDegrees(theta));
if ((diff > 0) && (m11 == 0))
return 0;
else if ((diff < 0) && (m11 == 0))
return -90;
else if ((diff > 0) && (m11 > 0)) // 0 to 45 degrees
return tilt;
else if ((diff > 0) && (m11 < 0)) // -45 to 0
return (180 + tilt); // change to counter-clockwise angle
else if ((diff < 0) && (m11 > 0)) // 45 to 90
return tilt;
else if ((diff < 0) && (m11 < 0)) // -90 to -45
return (180 + tilt); // change to counter-clockwise angle
System.out.println("Error in moments for tilt angle");
return 0;
} // end of calculateTilt()
Johannes Kilian在http://public.cranfield.ac.uk/c5354/teaching/dip/opencv/SimpleImageAnalysisbyMoments.pdf的Johannes Kilian撰写的“按时进行的简单图像分析”中对OpenCV中的时刻进行了深入的说明。 calculateTilt()内的代码基于Kilian论文表1中列出的θ特殊情况。
不幸的是,轴角无法区分手指指向上方的手和手指指向下方的手,因此有必要检查指尖相对于COG的相对位置,以决定是否应调整角度。 问题在于,只有在检查了手部轮廓的凸包是否存在缺陷(在extractContourInfo()完成之后才发生)之后,该信息才可用。
我的解决方案是使用在上一次调用update()时计算出的指尖坐标,该指针分析了当前帧之前的摄像头帧。 数据将是过时的,但是在两次捕捉之间的200毫秒间隔内指针不会移动太多。
1.3找到指尖
指尖的识别在图3的第一行中进行; 在代码中,通过OpenCV的cvConvexHull2()将凸包包裹在轮廓上,然后通过cvConvexityDefects()将多边形与轮廓进行比较以查找其缺陷。
通过使用轮廓的低多边形近似而不是原始的近似来加快船体创建和缺陷分析的速度。
这些阶段在findFingerTips()方法的前半部分执行:
// globals
private static final int MAX_POINTS = 20;
// max number of points stored in an array
// OpenCV elements
private CvMemStorage contourStorage, approxStorage,
hullStorage, defectsStorage;
// defects data for the hand contour
private Point[] tipPts, foldPts;
private float[] depths;
private void findFingerTips(CvSeq bigContour, int scale)
{
CvSeq approxContour = cvApproxPoly(bigContour,
Loader.sizeof(CvContour.class),
approxStorage, CV_POLY_APPROX_DP, 3, 1);
// reduce number of points in the contour
CvSeq hullSeq = cvConvexHull2(approxContour,
hullStorage, CV_COUNTER_CLOCKWISE, 0);
// find the convex hull around the contour
CvSeq defects = cvConvexityDefects(approxContour,
hullSeq, defectsStorage);
// find the defect differences between the contour and hull
int defectsTotal = defects.total();
if (defectsTotal > MAX_POINTS) {
System.out.println("Processing " + MAX_POINTS + " defect pts");
defectsTotal = MAX_POINTS;
}
// copy defect information from defects sequence into arrays
for (int i = 0; i < defectsTotal; i++) {
Pointer pntr = cvGetSeqElem(defects, i);
CvConvexityDefect cdf = new CvConvexityDefect(pntr);
CvPoint startPt = cdf.start();
tipPts[i] = new Point( (int)Math.round(startPt.x()*scale),
(int)Math.round(startPt.y()*scale));
// array contains coords of the fingertips
CvPoint endPt = cdf.end();
CvPoint depthPt = cdf.depth_point();
foldPts[i] = new Point( (int)Math.round(depthPt.x()*scale),
(int)Math.round(depthPt.y()*scale));
//array contains coords of the skin fold between fingers
depths[i] = cdf.depth()*scale;
// array contains distances from tips to folds
}
reduceTips(defectsTotal, tipPts, foldPts, depths);
} // end of findFingerTips()
findFingerTips()的后半部分从缺陷序列中提取尖端和褶皱坐标以及深度。 之前使用CV_COUNTER_CLOCKWISE参数调用凸包方法cvConvexHull2()意味着将以逆时针顺序存储坐标,如图7所示。
指尖存储在tipPts []数组中,手指在foldPts []中折叠(手指之间的凹痕),深度在depths []中。
如图7所示,分析通常会产生太多缺陷,因此在findFingerTips()的末尾会调用reduceTips()。 它应用了两个简单的测试来滤除不太可能是指尖的缺陷-丢弃缺陷深度较浅的点,并在其相邻折叠点之间以太大的角度进行坐标。 两者的示例如图8所示。
reduceTips()将其余的提示点存储在全局fingerTips列表中:
// globals
private static final int MIN_FINGER_DEPTH = 20;
private static final int MAX_FINGER_ANGLE = 60; // degrees
private ArrayList fingerTips;
private void reduceTips(int numPoints, Point[] tipPts,
Point[] foldPts, float[] depths)
{
fingerTips.clear();
for (int i=0; i < numPoints; i++) {
if (depths[i] < MIN_FINGER_DEPTH) // defect too shallow
continue;
// look at fold points on either side of a tip
int pdx = (i == 0) ? (numPoints-1) : (i - 1); // predecessor of i
int sdx = (i == numPoints-1) ? 0 : (i + 1); // successor of i
int angle = angleBetween(tipPts[i], foldPts[pdx], foldPts[sdx]);
if (angle >= MAX_FINGER_ANGLE)
continue; // angle between finger and folds too wide
// this point is probably a fingertip, so add to list
fingerTips.add(tipPts[i]);
}
} // end of reduceTips()
private int angleBetween(Point tip, Point next, Point prev)
// calculate the angle between the tip and its neighboring folds
// (in integer degrees)
{
return Math.abs( (int)Math.round(
Math.toDegrees(
Math.atan2(next.x - tip.x, next.y - tip.y) -
Math.atan2(prev.x - tip.x, prev.y - tip.y)) ));
}
1.4命名手指
nameFingers()使用指尖坐标列表以及轮廓的COG和轴角度来分两步标记手指。 首先,它会基于它们相对于COG的可能角度调用labelThumbIndex()来标记拇指和食指,假设它们位于手的左侧。 nameFingers()尝试根据相对于拇指和食指的已知顺序在labelUnknowns()中标记其他手指。
// globals
private ArrayList namedFingers;
private void nameFingers(Point cogPt, int contourAxisAngle,
ArrayList fingerTips)
{ // reset all named fingers to unknown
namedFingers.clear();
for (int i=0; i < fingerTips.size(); i++)
namedFingers.add(FingerName.UNKNOWN);
labelThumbIndex(fingerTips, namedFingers);
labelUnknowns(namedFingers);
} // end of nameFingers()
Finger ID及其相对顺序在FingerName枚举中维护:
public enum FingerName {
LITTLE, RING, MIDDLE, INDEX, THUMB, UNKNOWN;
public FingerName getNext()
{
int nextIdx = ordinal()+1;
if (nextIdx == (values().length))
nextIdx = 0;
return values()[nextIdx];
} // end of getNext()
public FingerName getPrev()
{
int prevIdx = ordinal()-1;
if (prevIdx < 0)
prevIdx = values().length-1;
return values()[prevIdx];
} // end of getPrev()
} // end of FingerName enum
可能的手指名称之一是UNKNOWN,该名称用于在调用命名方法之前标记所有指尖。
labelThumbIndex()尝试根据图9中所示的角度范围来标记拇指和食指。
食指可以围绕COG旋转60至120度,而拇指可以在120至200度之间移动。 我通过反复试验得出了这些角度,他们认为手是笔直向上的。
labelThumbIndex()还假设拇指和食指最有可能存储在fingerTips列表的末尾,因为轮廓船体是按逆时针顺序构建的。 因此,通过向后遍历列表,可以增加与正确缺陷匹配的机会。
// globals
private static final int MIN_THUMB = 120; // angle ranges
private static final int MAX_THUMB = 200;
private static final int MIN_INDEX = 60;
private static final int MAX_INDEX = 120;
// hand details
private Point cogPt
private int contourAxisAngle;
private void labelThumbIndex(ArrayList fingerTips,
ArrayList nms)
{
boolean foundThumb = false;
boolean foundIndex = false;
int i = fingerTips.size()-1;
while ((i >= 0)) {
int angle = angleToCOG(fingerTips.get(i),
cogPt, contourAxisAngle);
// check for thumb
if ((angle <= MAX_THUMB) && (angle>MIN_THUMB) && !foundThumb) {
nms.set(i, FingerName.THUMB);
foundThumb = true;
}
// check for index
if ((angle <= MAX_INDEX) && (angle > MIN_INDEX) && !foundIndex) {
nms.set(i, FingerName.INDEX);
foundIndex = true;
}
i--;
}
} // end of labelThumbIndex()
angleToCOG()计算指尖相对于COG的角度,记住要记住轮廓轴角度,以便使手笔直向上。
private int angleToCOG(Point tipPt, Point cogPt,
int contourAxisAngle)
{
int yOffset = cogPt.y - tipPt.y; // make y positive up screen
int xOffset = tipPt.x - cogPt.x;
double theta = Math.atan2(yOffset, xOffset);
int angleTip = (int) Math.round( Math.toDegrees(theta));
return angleTip + (90 - contourAxisAngle);
// this ensures that the hand is orientated straight up
} // end of angleToCOG()
labelUnknowns()传递了一个手指名称列表,该列表希望在某些位置包含THUMB和INDEX,而在其他位置包含UNKNOWN。 使用命名的手指作为起点,根据手指在FingerName枚举中的顺序,将UNKNOWN更改为手指名称。
private void labelUnknowns(ArrayList nms)
{
// find first named finger
int i = 0;
while ((i < nms.size()) && (nms.get(i) == FingerName.UNKNOWN))
i++;
if (i == nms.size()) // no named fingers found, so give up
return;
FingerName name = nms.get(i);
labelPrev(nms, i, name); // fill-in backwards
labelFwd(nms, i, name); // fill-in forwards
} // end of labelUnknowns()
labelPrev()和labelFwd()的区别仅在于它们在名称列表中移动的方向。 labelPrev()向后移动以尝试将UNKNOWNS更改为已命名的手指,但前提是尚未将名称分配给列表。
2.画出手指
由update()执行的分析将产生指尖点列表(在全局fingerTips中),关联的已命名手指的列表(在namedFingers中)以及轮廓COG和轴角度。 除角度外,所有这些都由draw()用来将命名的手指标签添加到网络摄像头图像中,如下图1和图10所示。
一个未知的手指“尖端”(在namedFingers中标记为UNKNOWN)被绘制为红色圆圈。
// globals
private Point cogPt;
private ArrayList fingerTips;
private ArrayList namedFingers;
public void draw(Graphics2D g2d)
{
if (fingerTips.size() == 0)
return;
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON); // line smoothing
g2d.setPaint(Color.YELLOW);
g2d.setStroke(new BasicStroke(4)); // thick yellow pen
// label tips in red or green, and draw lines to named tips
g2d.setFont(msgFont);
for (int i=0; i < fingerTips.size(); i++) {
Point pt = fingerTips.get(i);
if (namedFingers.get(i) == FingerName.UNKNOWN) {
g2d.setPaint(Color.RED); // unnamed fingertip is red
g2d.drawOval(pt.x-8, pt.y-8, 16, 16);
g2d.drawString("" + i, pt.x, pt.y-10); // label with a digit
}
else { // draw yellow line to the named fingertip from COG
g2d.setPaint(Color.YELLOW);
g2d.drawLine(cogPt.x, cogPt.y, pt.x, pt.y);
g2d.setPaint(Color.GREEN); // named fingertip is green
g2d.drawOval(pt.x-8, pt.y-8, 16, 16);
g2d.drawString(namedFingers.get(i).toString().toLowerCase(),
pt.x, pt.y-10);
}
}
// draw COG
g2d.setPaint(Color.GREEN);
g2d.fillOval(cogPt.x-8, cogPt.y-8, 16, 16);
} // end of draw()
3.手势检测
Handy应用程序会尽力将已命名的指尖转换为手势,这需要分析手指随着时间在空间中的移动方式。
初步测试表明,Handy仅在涉及伸出的拇指和/或食指(也许与其他手指结合在一起)时,才能可靠地识别手势。 这种手势包括图11所示的“胜利”,“波浪”,“良好”,“指向”和“枪支”。
Handy无法检测到的常见手势是“ ok”(参见图12),因为它需要将手指放在一起,而这不能仅根据轮廓缺陷来检测到。
参考: Java Advent Calendar博客上的JCG合作伙伴 Attila-Mihaly Balazs 使用JavaCV进行的手和手指检测 。
翻译自: https://www.javacodegeeks.com/2012/12/hand-and-finger-detection-using-javacv.html