实时平滑 Kinect 深度帧






4.91/5 (39投票s)
使用像素滤镜和加权移动平均技术,实时去除Kinect深度帧中的噪声。
引言
我已经用Microsoft Kinect for Xbox 360在我的PC上工作了几个月了,总的来说我觉得它非常棒!然而,有一件事一直困扰着我,那就是渲染深度帧图像的质量似乎很差。深度帧中有很多噪声,有缺失的部分,还有相当严重的闪烁问题。Kinect的帧率还不错,最高可达30 fps;但是,由于数据中存在随机噪声,它会吸引您的注意力到刷新上。在本文中,我将向您展示我解决此问题的方法。我将实时平滑从Kinect获取并渲染到屏幕的深度帧。这通过两种组合方法实现:像素过滤和加权移动平均。
背景
关于Kinect的一些信息
到目前为止,我想每个人都至少听说过Kinect并理解其基本原理。它是由微软制造的专用传感器,能够识别和跟踪3D空间中的人类。它是如何做到这一点的呢?虽然Kinect确实有两个摄像头,但它并非通过立体光学实现3D传感。一种称为光编码的技术使3D传感成为可能。
在Kinect上,有一个红外(IR)投影仪、一个彩色(RGB)摄像头和一个红外(IR)传感器。为了实现3D传感,红外投影仪会向其前方发射一个红外光网格。然后,这种光线会从其路径上的物体反射回来,并反射到红外传感器。红外传感器接收到的图案随后在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深度数据可获得的最大分辨率是320x240,但即使是这个分辨率,质量看起来也确实很差。数据中的噪声表现为图片中不断出现的白点。数据中的一些噪声来自红外光被其所撞击的物体散射,一些来自靠近Kinect的物体的阴影。我戴眼镜,并且由于红外光散射,我的眼镜所在位置经常出现噪声。
深度数据的另一个限制是它的可见距离有限。目前的限制大约是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
选择这种加权方法是由于对运动数据进行平均可能对最终渲染产生模糊效应。如果你静止不动,一个简单的平均值在队列中包含少量项目时也能很好地工作。然而,一旦你开始移动,无论你去哪里,身后都会有一个明显的拖影。使用加权移动平均仍然会出现这种情况,但效果不那么明显。其代码如下
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。
延伸阅读
如果这个话题引起您的兴趣,我强烈推荐阅读微软研究院关于KinectFusion的论文:《实时密集表面映射与跟踪》。他们在这个特定领域做了一些了不起的工作。然而,我认为您无法用.NET实现这些结果:http://research.microsoft.com/pubs/155378/ismar2011.pdf。
历史
- 2012年1月21日 - 第一个版本。
- 2012年1月22日 - 更新了文章和下载,包含了评论中来自jpmik的建议。
- 2012年1月24日 - 更新了像素过滤方法,使其更加准确。