实时平滑 Kinect 深度帧






4.91/5 (39投票s)
使用像素滤波器和加权移动平均技术实时去除Kinect深度帧中的噪点。
引言
几个月来,我一直在PC上使用Microsoft Kinect for Xbox 360,总体来说我发现它很棒!然而,一件一直困扰我的事情是深度帧图像渲染质量似乎不高。深度帧中有很多噪点,有丢失的部分,还有一个相当严重的闪烁问题。Kinect的帧率还不算差,最高大约30 fps;然而,由于数据中存在的随机噪点,这会吸引你的注意力到刷新率上。在这篇文章中,我将向您展示我解决这个问题的方法。我将对来自Kinect并渲染到屏幕上的深度帧进行实时平滑处理。这是通过两种结合的方法实现的:像素滤波和加权移动平均。
背景
关于Kinect的一些信息
现在,我猜每个人至少都听说过Kinect,并且了解其基本原理。它是由Microsoft制造的专用传感器,能够识别和跟踪3D空间中的人类。它是如何做到这一点的?虽然Kinect确实有两个摄像头,但它并不是通过立体光学来实现3D感知的。一种称为“光编码”(Light Coding)的技术使得3D感知成为可能。
Kinect上有一个红外(IR)投影仪、一个彩色(RGB)摄像头和一个红外(IR)传感器。为了进行3D感知,IR投影仪在其前方发射一束IR光。然后,光线会反射到路径中的物体上,并反射回IR传感器。IR传感器接收到的图案随后会在Kinect中进行解码,以确定深度信息,并通过USB发送到另一个设备进行进一步处理。这些深度信息在计算机视觉应用中非常有用。作为Kinect Beta SDK的一部分,这些深度信息被用来确定人体上的关节位置,从而使我们这样的开发者能够想出各种有用的应用程序和功能。
重要的设置信息
在下载演示应用程序源代码或演示应用程序可执行文件的链接之前,您需要准备好您的开发环境。要使用此应用程序,您需要在您的机器上安装Kinect Beta 2 SDK:http://www.microsoft.com/en-us/kinectforwindows/download/。
在发布本文时,商业SDK尚未发布。请务必仅使用Kinect Beta 2 SDK下载本文中的内容。此外,SDK安装时附带几个演示应用程序;在下载本文的文件的请确保这些应用程序能在您的机器上运行。
深度数据的挑战
在我深入研究解决方案之前,请允许我更好地阐述这个问题。下面是一张原始深度数据渲染成图像的截图以供参考。离Kinect越近的物体颜色越浅,离Kinect越远的物体颜色越深。
您看到的是我坐在书桌前的照片。我坐在中间;左边是一个书柜,右边是一棵假圣诞树。正如您已经可以看出,即使没有视频流的闪烁,质量也相当低。Kinect深度数据的最大分辨率是320x240,但即使是这个分辨率,质量看起来也非常差。数据中的噪点表现为不断出现和消失的白点。一些噪点来自IR光被其照射的物体散射,一些来自离Kinect更近的物体的阴影。我戴着眼镜,由于IR光的散射,我的眼镜所在的位置经常有噪点。
深度数据的另一个限制是它有视距限制。目前的限制大约是8米。您看到我身后那个巨大的白色方块了吗?那不是离Kinect很近的物体;我所在的房间实际上比那个白色方块还要延伸大约一米。Kinect就是这样处理它无法用深度感应看到的物体的,返回的深度值为零。
解决方案
正如我曾简要提到的,我开发的解决方案使用了两种不同的深度数据平滑方法:像素滤波和加权移动平均。这两种方法可以单独使用,也可以串联使用以产生平滑的输出。虽然该解决方案不能完全消除所有噪点,但它确实产生了可观的效果。我使用的解决方案不会降低帧率,并且能够实时输出到屏幕或进行录制。
像素滤波
像素滤波过程的第一步是将Kinect的深度数据转换为更容易处理的形式。
private short[] CreateDepthArray(ImageFrame image)
{
short[] returnArray = new short[image.Image.Width * image.Image.Height];
byte[] depthFrame = image.Image.Bits;
// Porcess each row in parallel
Parallel.For(0, 240, depthImageRowIndex =>
{
// Process each pixel in the row
for (int depthImageColumnIndex = 0; depthImageColumnIndex < 640; depthImageColumnIndex += 2)
{
var depthIndex = depthImageColumnIndex + (depthImageRowIndex * 640);
var index = depthIndex / 2;
returnArray[index] =
CalculateDistanceFromDepth(depthFrame[depthIndex], depthFrame[depthIndex + 1]);
}
});
return returnArray;
}
此方法创建一个简单的short[]
,其中包含每个像素的深度值。深度值是从Kinect每次推送新帧时发送的ImageFrame
的byte[]
计算得出的。对于每个像素,ImageFrame
的byte[]
有两个值。
private short CalculateDistanceFromDepth(byte first, byte second)
{
// Please note that this would be different if you
// use Depth and User tracking rather than just depth
return (short)(first | second << 8);
}
现在我们有了一个更容易处理的数组,我们可以开始将其应用到实际的滤波器上。我们逐像素地扫描整个数组,查找零值。这些是Kinect未能正确处理的值。我们希望在不降低性能或不影响数据其他特征(稍后详述)的情况下,尽可能多地去除这些零值。
当我们发现数组中的零值时,它就被视为滤波的候选值,我们必须仔细查看。特别是,我们需要查看邻近的像素。滤波器有效地在候选像素周围有两个“带”,用于搜索其他像素中的非零值。滤波器会创建这些值的频率分布,并记录每个带中找到的数量。然后,它会将这些值与每个带的任意阈值进行比较,以确定是否应过滤候选值。如果任一阈值被打破,则将所有非零值的统计众数应用于候选值,否则将其保持不变。
此方法最大的考虑因素是确保滤波器的带确实包围着在渲染图像中显示的像素,而不仅仅是深度数组中相邻的值。应用此滤波器的代码如下:
short[] smoothDepthArray = new short[depthArray.Length];
// We will be using these numbers for constraints on indexes
int widthBound = width - 1;
int heightBound = height - 1;
// We process each row in parallel
Parallel.For(0, 240, depthArrayRowIndex =>
{
// Process each pixel in the row
for (int depthArrayColumnIndex = 0; depthArrayColumnIndex < 320; depthArrayColumnIndex++)
{
var depthIndex = depthArrayColumnIndex + (depthArrayRowIndex * 320);
// We are only concerned with eliminating 'white' noise from the data.
// We consider any pixel with a depth of 0 as a possible candidate for filtering.
if (depthArray[depthIndex] == 0)
{
// From the depth index, we can determine the X and Y coordinates that the index
// will appear in the image. We use this to help us define our filter matrix.
int x = depthIndex % 320;
int y = (depthIndex - x) / 320;
// The filter collection is used to count the frequency of each
// depth value in the filter array. This is used later to determine
// the statistical mode for possible assignment to the candidate.
short[,] filterCollection = new short[24,2];
// The inner and outer band counts are used later to compare against the threshold
// values set in the UI to identify a positive filter result.
int innerBandCount = 0;
int outerBandCount = 0;
// The following loops will loop through a 5 X 5 matrix of pixels surrounding the
// candidate pixel. This defines 2 distinct 'bands' around the candidate pixel.
// If any of the pixels in this matrix are non-0, we will accumulate them and count
// how many non-0 pixels are in each band. If the number of non-0 pixels breaks the
// threshold in either band, then the average of all non-0 pixels in the matrix is applied
// to the candidate pixel.
for (int yi = -2; yi < 3; yi++)
{
for (int xi = -2; xi < 3; xi++)
{
// yi and xi are modifiers that will be subtracted from and added to the
// candidate pixel's x and y coordinates that we calculated earlier. From the
// resulting coordinates, we can calculate the index to be addressed for processing.
// We do not want to consider the candidate
// pixel (xi = 0, yi = 0) in our process at this point.
// We already know that it's 0
if (xi != 0 || yi != 0)
{
// We then create our modified coordinates for each pass
var xSearch = x + xi;
var ySearch = y + yi;
// While the modified coordinates may in fact calculate out to an actual index, it
// might not be the one we want. Be sure to check
// to make sure that the modified coordinates
// match up with our image bounds.
if (xSearch >= 0 && xSearch <= widthBound &&
ySearch >= 0 && ySearch <= heightBound)
{
var index = xSearch + (ySearch * width);
// We only want to look for non-0 values
if (depthArray[index] != 0)
{
// We want to find count the frequency of each depth
for (int i = 0; i < 24; i++)
{
if (filterCollection[i, 0] == depthArray[index])
{
// When the depth is already in the filter collection
// we will just increment the frequency.
filterCollection[i, 1]++;
break;
}
else if (filterCollection[i, 0] == 0)
{
// When we encounter a 0 depth in the filter collection
// this means we have reached the end of values already counted.
// We will then add the new depth and start it's frequency at 1.
filterCollection[i, 0] = depthArray[index];
filterCollection[i, 1]++;
break;
}
}
// We will then determine which band the non-0 pixel
// was found in, and increment the band counters.
if (yi != 2 && yi != -2 && xi != 2 && xi != -2)
innerBandCount++;
else
outerBandCount++;
}
}
}
}
}
// Once we have determined our inner and outer band non-zero counts, and
// accumulated all of those values, we can compare it against the threshold
// to determine if our candidate pixel will be changed to the
// statistical mode of the non-zero surrounding pixels.
if (innerBandCount >= innerBandThreshold || outerBandCount >= outerBandThreshold)
{
short frequency = 0;
short depth = 0;
// This loop will determine the statistical mode
// of the surrounding pixels for assignment to
// the candidate.
for (int i = 0; i < 24; i++)
{
// This means we have reached the end of our
// frequency distribution and can break out of the
// loop to save time.
if (filterCollection[i,0] == 0)
break;
if (filterCollection[i, 1] > frequency)
{
depth = filterCollection[i, 0];
frequency = filterCollection[i, 1];
}
}
smoothDepthArray[depthIndex] = depth;
}
}
else
{
// If the pixel is not zero, we will keep the original depth.
smoothDepthArray[depthIndex] = depthArray[depthIndex];
}
}
});
关于原始帖子以来更改的说明
我最近更新了此过滤器,使其比我的原始帖子更准确。在我最初的帖子中,如果任何带阈值被打破,则将滤波器矩阵中所有非零像素的统计平均值赋给候选像素;我已经将其更改为使用统计众数。这有什么区别?
考虑上图代表的理论滤波矩阵深度值。从这些值中,我们可以直观地识别出我们的滤波矩阵中可能存在一个物体边缘。如果我们应用所有这些值的平均值来处理候选像素,它会从X,Y视角去除噪点,但会在Z,Y视角引入噪点;将候选像素的深度置于两个独立特征之间。通过使用统计众数,我们基本上可以确保为候选像素分配一个与滤波器矩阵中最显著特征相匹配的深度。
我说“基本上”,因为仍然有可能由于深度读数中的微小差异而将次要特征识别为主导特征;但这对结果影响微乎其微。解决此问题的方法涉及数据离散化,这值得单独一篇文章来讨论。
加权移动平均
现在我们有了滤波后的深度数组,我们可以继续计算任意数量的先前深度数组的加权移动平均值。我们这样做是为了减少深度数组中剩余的随机噪声产生的闪烁效果。以30 fps的速度,您会非常注意到闪烁。我之前尝试过一种交错技术来减少闪烁,但它从来没有像我希望的那样流畅。在尝试了几种其他方法后,我选择了加权移动平均。
我们设置一个Queue<short[]>
来存储我们最近的N个深度数组。由于Queue是FIFO(先进先出)集合对象,它们有很好的方法来处理离散的时间序列数据。然后,我们将最近深度数组的重要性赋予最高,最旧的深度数组的重要性赋予最低。通过对Queue中的深度帧进行加权平均,创建一个新的深度数组。
选择这种加权方法是因为平均运动数据会对最终渲染产生模糊效果。如果您站着不动,那么将Queue中的项目数量较少时,直接平均就可以了。然而,一旦您开始移动,您走到哪里都会留下明显的拖影。使用加权移动平均仍然会出现这种情况,但影响会较小。代码如下:
averageQueue.Enqueue(depthArray);
CheckForDequeue();
int[] sumDepthArray = new int[depthArray.Length];
short[] averagedDepthArray = new short[depthArray.Length];
int Denominator = 0;
int Count = 1;
// REMEMBER!!! Queue's are FIFO (first in, first out).
// This means that when you iterate over them, you will
// encounter the oldest frame first.
// We first create a single array, summing all of the pixels
// of each frame on a weighted basis and determining the denominator
// that we will be using later.
foreach (var item in averageQueue)
{
// Process each row in parallel
Parallel.For(0,240, depthArrayRowIndex =>
{
// Process each pixel in the row
for (int depthArrayColumnIndex = 0; depthArrayColumnIndex < 320; depthArrayColumnIndex++)
{
var index = depthArrayColumnIndex + (depthArrayRowIndex * 320);
sumDepthArray[index] += item[index] * Count;
}
});
Denominator += Count;
Count++;
}
// Once we have summed all of the information on a weighted basis,
// we can divide each pixel by our denominator to get a weighted average.
Parallel.For(0, depthArray.Length, i =>
{
averagedDepthArray[i] = (short)(sumDepthArray[i] / Denominator);
});
渲染图像
现在我们已经将两种平滑技术应用于深度数据,我们可以将图像渲染到Bitmap
中。
// We multiply the product of width and height by 4 because each byte
// will represent a different color channel per pixel in the final iamge.
byte[] colorFrame = new byte[width * height * 4];
// Process each row in parallel
Parallel.For(0, 240, depthArrayRowIndex =>
{
// Process each pixel in the row
for (int depthArrayColumnIndex = 0; depthArrayColumnIndex < 320; depthArrayColumnIndex++)
{
var distanceIndex = depthArrayColumnIndex + (depthArrayRowIndex * 320);
// Because the colorFrame we are creating has four times as many bytes representing
// a pixel in the final image, we set the index to be the depth index * 4.
var index = distanceIndex * 4;
// Map the distance to an intesity that can be represented in RGB
var intensity = CalculateIntensityFromDistance(depthArray[distanceIndex]);
// Apply the intensity to the color channels
colorFrame[index + BlueIndex] = intensity;
colorFrame[index + GreenIndex] = intensity;
colorFrame[index + RedIndex] = intensity;
}
});
汇总
现在我已经向您展示了平滑过程的一些代码和理论,让我们来看看如何使用上面链接中提供的演示应用程序。
如您所见,演示应用程序将并排显示原始深度图像和已平滑的深度图像。您还可以尝试调整应用程序中的平滑设置。首次运行应用程序时找到的设置是我推荐的通用设置。它在平滑静止物体、移动物体方面达到了很好的平衡,并且不会试图从滤波方法中“填充”过多。
例如:您可以将两个带滤波器都调低到1,然后将加权移动平均调高到10,您将获得对静止的钝物体的最低闪烁和噪点。但是,一旦您移动,您将有一个非常明显的拖影,并且如果后面没有墙,您的手指看起来都会像蹼一样。
关注点
我非常喜欢玩弄这些平滑技术,并认识到可能没有“一刀切”的解决方案。即使使用相同的硬件,您的物理环境和意图将比其他任何因素都更能驱动您对平滑的选择。我鼓励您打开代码自己看看,并分享您改进的建议!至少,去借您邻居家孩子的Kinect玩一天,试用一下演示应用程序。
另一个有趣的方面是看到如何减少用于渲染的噪点实际上会为深度计算引入噪点。用于减少噪点的像素滤波方法本身在进一步计算之前就很好地减少了深度信息中的噪点,消除了“白色”噪点并提供了对该信息应为何物的最佳猜测。加权移动平均方法可以很好地在渲染之前减少噪点,但由于平均深度信息的效果,它实际上会在Z,Y视角引入噪点。我希望继续学习这些效果以及如何为不同类型的应用程序正确使用它们。
我将用一个简短的演示视频来结束,视频中展示了演示应用程序。在视频中,我基本上只是坐着挥舞手臂,但这能让您很好地了解这些技术的能力。我会在70秒内遍历所有功能的组合,没有音频。
请记住,由于YouTube的低帧率,几乎不可能在关闭加权移动平均时看到闪烁的变化。您只能相信我,或者下载代码;这简直是天壤之别。
这是YouTube视频的直接链接:http://youtu.be/YZ64kJ--aeg。
延伸阅读
如果这个主题引起您的兴趣,我强烈建议阅读Microsoft Research关于KinectFusion:Real-Time Dense Surface Mapping and Tracking的论文。他们在这一特定领域做了令人惊叹的工作。但是,我认为您永远无法在.NET中实现这些结果:http://research.microsoft.com/pubs/155378/ismar2011.pdf。
历史
- 2012年1月21日 - 第一个版本。
- 2012年1月22日 - 更新了文章和下载内容,加入了来自评论区jpmik的建议。
- 2012年1月24日 - 更新了像素滤波方法,使其更准确。