为机器人构建用户界面 - 第二部分 b。





5.00/5 (2投票s)
第二篇文章的续篇,我们将介绍 3D 环境和一些更复杂的 Visualization ToolKit (VTK) 主题。
第 2b 部分 - VTK 可视化
本文讨论了 VTK 中更复杂的主题,每个主题都针对 3D 环境的一个用例——复杂模型、地形和传感器可视化。项目中包含了两种不同类型的传感器可视化——一个视频流屏幕和一个 LIDAR 扫描仪点云。上一篇文章讨论了如何在 VTK 中设置基本环境,即如何创建窗口和渲染器,构建一些基本图元,并将它们包含在场景中。这将用于为环境构建一个完整的“Bot”类。随着项目的不断扩大,将完整的代码示例复制到文章中变得困难。因此,从本文开始,将只讨论代码片段,以节省篇幅。本文讨论的每个主题几乎都是一个独立的问题。讨论的主题如下:
-
我有基本模型(球体、圆柱体等),但我想导入复杂模型,例如来自 Blender 或 3DS Max 的模型,我该怎么做?
-
如何为模型指定纹理?
-
-
如何在我的环境中包含地形(也称为高度图)?
-
如何可视化机器人传感器信息?以下内容用作示例:
-
机器人的视频流屏幕
-
机器人的 LIDAR 流
-
导入复杂模型
在各种项目中,您可能需要包含复杂的道具模型或数据源。对于道具,这些模型可以在 Blender 或 3DS Max 中加载,并导出为合适的格式(本项目中为 STL 和 OBJ 格式)。本节将介绍如何加载这些模型并将它们添加到可视化环境中。要从文件加载模型,您需要:
-
将文件导出为可导入的文件格式
-
有多种不同的文件格式可供加载——大多数格式在 ParaView 用户指南 中有描述。一般来说,我一直使用立体光刻(*.STL)和 WaveFront(*.OBJ)格式,因为它们似乎很容易导入。
-
在查找模型方面,您可以在 TurboSquid 上找到大量免费模型,它们采用常见的文件格式。
-
您可以使用 Blender(免费)、MilkShape(便宜)或 3DS Max(便宜……如果您开着 Veyron)将文件导出为可读格式。
-
对于本项目,我们将“上”设置为 Y 轴,“前”设置为 Z 轴,因此在导出之前,您可能需要旋转模型,使其沿 Y 轴向上,沿 Z 轴向前。
-
最后,在导出模型之前,请检查模型是否位于原点,否则它将具有一个奇怪的偏移量,并且将围绕该偏移量旋转。
-
-
创建一个新的 Python 模块/类,它继承自“SceneObject”类。
-
在构造函数(__init__() 方法)中:
-
创建用于读取文件的特定读取器。本项目中使用 OBJ 或 STL 文件,因此两个可能的读取器分别是“vtkOBJReader”和“vtkSTLReader”。如果您使用的是其他格式,您需要在 VTK 类文档 中找到相应的读取器。
-
将读取器指向您的文件。
-
使用“vtkTransform”旋转/缩放模型,以防我们导出错误(对我来说,这种情况每次都会发生)。
-
为模型创建一个映射器(mapper)。
-
将映射器分配给继承自“SceneObject”类的现有“vtkActor”。
-
每个类的输入和输出的连接方式如下:读取器 -> 变换 -> 映射器 -> Actor。
-
-
模型将随之绘制并在场景中移动,如果我们使用上一篇文章中讨论的设置器和获取器来设置其位置。
在本项目的这部分中,我们将使用这一系列步骤将一个 Bot 模型添加到场景中(位于“scene”文件夹中的“Bot.py”)。该模型是从 TF3DM 下载并在 Blender 中进行清理的,但方向不正确,因此变换过滤器很有用。
快速讨论“Bot.py”中的步骤:
-
创建一个继承自“SceneObject.py”模块的 bot 类。
class Bot(SceneObject): ''' A template for loading complex models. '''
-
将文件名设置为模型文件在硬盘上的位置,该文件位于场景目录的 media 文件夹中,然后创建一个文件读取器。
# Call the parent constructor super(Bot,self).__init__(renderer) filename = "../scene/media/bot.stl" reader = vtk.vtkSTLReader() reader.SetFileName(filename)
-
创建一个变换,设置旋转,使机器人沿 Z 轴向前,并将读取器的输出连接到变换的输入。
# Do the internal transforms to make it look along unit-z, do it with a quick transform trans = vtk.vtkTransform() trans.RotateZ(90) transF = vtk.vtkTransformPolyDataFilter() transF.SetInputConnection(reader.GetOutputPort()) transF.SetTransform(trans)
-
创建一个映射器,将映射器的输入设置为变换的输出,并将“SceneObject”的 actor 设置为使用最终的映射器。
# Create a mapper self.__mapper = vtk.vtkPolyDataMapper() self.__mapper.SetInputConnection(transF.GetOutputPort()) self.vtkActor.SetMapper(self.__mapper)
-
调用一个(当前为空)方法,该方法将添加此模型的子项,并将模型重置到世界原点,以便子项能够更新。
# Set up all the children for this model self.__setupChildren(renderer) # Set it to [0,0,0] so that it updates all the children self.SetPositionVec3([0, 0, 0])
-
此方法用于添加视频流和 LIDAR 传感器。如果您查看最终的项目文件,它们已经被填充——这将随着我们介绍传感器而完成。空方法定义如下:
def __setupChildren(self, renderer): ''' Configure the children for this bot - camera and other sensors. ''' return
要在主程序中创建它,您只需要实例化一个 bot 类,并传入当前的渲染器(在本例中,我将几个机器人放在一个圆圈中)。
... # Initialize a set of test bots numBots = 8 bots = [] for i in xrange(0, numBots): bot = Bot.Bot(renderer) # Put the bot in a cool location location = [10 * cos(i / float(numBots) * 6.242), 0, 10 * sin(i / float(numBots) * 6.242)] bot.SetPositionVec3(location) # Make them all look outward yRot = 90.0 - i / float(numBots) * 360.0 bot.SetOrientationVec3([0, yRot, 0]) bots.append(bot) ....
添加纹理
在某些情况下,您可能希望为 UV 映射模型添加纹理。当前 bot 模型并未实现此功能,但稍后将用于视频流。
-
文本化非常直接,只需在 bot 类的 init 方法中添加一小段代码:
textReader = vtk.vtkPNGReader() textReader.SetFileName("[MY TEXTURE]") self.texture = vtk.vtkTexture() self.texture.SetInputConnection(textReader.GetOutputPort()) self.texture.InterpolateOn()
-
最后,将纹理分配给 actor 以应用它。
self.vtkActor.SetTexture(self.texture)
创建地形
地形就相当于表面的 3D 图。VTK 示例中有一个很好的表面绘图示例——“expCos.py”——本项目中的地形就是基于此构建的。要创建地形:
-
使用平面源(plane source)创建一个平坦的细分表面。
-
该源将提供一个与 XY 平面对齐的表面,该表面面向屏幕,因此使用另一个变换过滤器将其放置在地面(XZ 平面)上。
-
使用变形过滤器(warp filter)设置每个点的高度。
-
创建一个可编程过滤器(programmable filter),用于为地形上的每个高度点设置标量值——在本项目中,我们使用一个简单的圆形公式来为表面增加一些定义。
-
您可以轻松替换此步骤,并从纹理加载高度图。如果您想要代码片段,请告诉我。
-
使用变形过滤器根据标量值中找到的高度来变形地形。
-
应用一个映射器,该映射器将使用标量值进行着色,并告知其地形的高度范围,以便正确着色。
-
如前所述,您可以轻松地通过从纹理加载地形高度来替换第三步。
此代码可以在“scene”文件夹中的“Terrain.py”文件中找到。对实现的简要讨论:
-
“Terrain.py”类的初始化函数会接收一个“surfaceSize”参数,用于地形的总宽度和高度。
-
使用指定的参数创建平面源,将其缩放到“surfaceSize”参数,并旋转它使其成为地面平面。
... # We create a 'surfaceSize' by 'surfaceSize' point plane to sample plane = vtk.vtkPlaneSource() plane.SetXResolution(surfaceSize) plane.SetYResolution(surfaceSize) # We transform the plane by a factor of 'surfaceSize' on X and Y transform = vtk.vtkTransform() transform.Scale(surfaceSize, surfaceSize, 1) transF = vtk.vtkTransformPolyDataFilter() transF.SetInputConnection(plane.GetOutputPort()) transF.SetTransform(transform)
-
将一个可编程过滤器连接到变换的输出。
... # Compute the function that we use for the height generation. # [Original comment] Note the unusual GetPolyDataInput() & GetOutputPort() methods. surfaceF = vtk.vtkProgrammableFilter() surfaceF.SetInputConnection(transF.GetOutputPort())
-
为可编程过滤器创建一个函数(此处使用任意的圆形公式),并将其分配给可编程过滤器。
# [Original comment] The SetExecuteMethod takes a Python function as an argument # In here is where all the processing is done. def surfaceFunction(): input = surfaceF.GetPolyDataInput() numPts = input.GetNumberOfPoints() newPts = vtk.vtkPoints() derivs = vtk.vtkFloatArray() for i in range(0, numPts): x = input.GetPoint(i) x, z = x[:2] # Get the XY plane point, which we'll make an XZ plane point so that's it a ground surface - this is a convenient point to remap it... # Now do your surface construction here, which we'll just make an arbitrary wavy surface for now. y = sin(x / float(surfaceSize) * 6.282) * cos(z / float(surfaceSize) * 6.282) newPts.InsertPoint(i, x, y, z) derivs.InsertValue(i, y) surfaceF.GetPolyDataOutput().CopyStructure(input) surfaceF.GetPolyDataOutput().SetPoints(newPts) surfaceF.GetPolyDataOutput().GetPointData().SetScalars(derivs) surfaceF.SetExecuteMethod(surfaceFunction)
-
变形表面(通过将变形过滤器连接到可编程过滤器的输出)。
# We warp the plane based on the scalar values calculated above warp = vtk.vtkWarpScalar() warp.SetInputConnection(surfaceF.GetOutputPort()) warp.XYPlaneOn()
-
创建映射器(默认情况下,它会将标量数据解释为颜色),并告知它将最亮/最暗点设置为地形的最大/最小高度。
# Set the range of the colour mapper to the function min/max we used to generate the terrain. mapper = vtk.vtkPolyDataMapper() mapper.SetInputConnection(warp.GetOutputPort()) mapper.SetScalarRange(-1, 1)
- 最后,将地形设置为线框(严格可选,但会让它看起来很有 90 年代的复古风格),并将映射器分配给“SceneObject”actor 以便绘制。
# Make our terrain wireframe so that it doesn't occlude the whole scene self.vtkActor.GetProperty().SetRepresentationToWireframe() # Finally assign this to the parent class actor so that it draws. self.vtkActor.SetMapper(mapper)
要在“bot_vis_main.py”中将其添加到场景中,使用了与之前相同的方法(这几乎是标准做法,但在这里我们为其提供了一个大小参数——此处设置为 100)。
... # Create our new scene objects... terrain = Terrain.Terrain(renderer, 100) ...
构建传感器显示
添加到场景的最后一项将是机器人的一些传感器显示。本项目假设我们有来自机器人的视频流和 LIDAR 流,并且希望在 3D 环境中表示它们。每个传感器显示都以与先前示例相同的方式构建,即继承“SceneObject”类,但它们都在实现上增加了一点复杂性。
- 视频屏幕流将包含一个视频流纹理(在前面几部分中简要讨论过)。
- LIDAR 流将利用点云来绘制深度图像。
视频流屏幕
视频流屏幕的代码可以在“scene”项目文件夹中的“CameraScreen.py”文件中找到。
这里对代码进行简要介绍:
- 像创建地形一样,创建一个平面源。
... def __init__(self, renderer, screenDistance, width, height): ''' Initialize the CameraScreen model. ''' # Call the parent constructor super(CameraScreen,self).__init__(renderer) # Create a plane for the camera # Ref: http://www.vtk.org/doc/nightly/html/classvtkPlaneSource.html planeSource = vtk.vtkPlaneSource()
- 平面将面向 XZ 轴,我们希望将其沿 Z 轴“推”一点,这样当它位于机器人前方时,它会比机器人“screenDistance”单位远(在它前面)。
# Defaults work for this, so just push it out a bit #planeSource.Push(screenDistance)
- 再次使用变换将其缩放到指定的视频大小(“CameraScreen.py”构造函数的两个参数),并将其翻转,使其不会被镜像绘制。
# Transform scale it to the right size trans = vtk.vtkTransform() trans.Scale(width, height, 1) trans.Translate(0, 0, screenDistance) trans.RotateY(180) # Effectively flipping the UV (texture) mapping so that the video isn't left/right flipped transF = vtk.vtkTransformPolyDataFilter() transF.SetInputConnection(planeSource.GetOutputPort()) transF.SetTransform(trans)
- 读取纹理。
# Create a test picture and assign it to the screen for now... # Ref: http://vtk.org/gitweb?p=VTK.git;a=blob;f=Examples/Rendering/Python/TPlane.py textReader = vtk.vtkPNGReader() textReader.SetFileName("../scene/media/semisortedcameralogo.png") self.cameraVtkTexture = vtk.vtkTexture() self.cameraVtkTexture.SetInputConnection(textReader.GetOutputPort()) self.cameraVtkTexture.InterpolateOn()
- 最后,创建一个映射器(与地形示例相同),并将纹理和映射器分配给 actor。
# Finally assign the mapper and the actor planeMapper = vtk.vtkPolyDataMapper() planeMapper.SetInputConnection(transF.GetOutputPort()) self.vtkActor.SetMapper(planeMapper) self.vtkActor.SetTexture(self.cameraVtkTexture)
接下来,我们需要编辑 bot 类。创建一个摄像头并将其分配为 bot 的子项之一(请记住,“SceneObject.py”具有位置和方向绑定的子项)。这在“Bot.__setupChildren()”方法中完成。
... def __setupChildren(self, renderer): ''' Configure the children for this bot - camera and other sensors. ''' # Create a camera screen and set the child's offset. self.camScreen = CameraScreen.CameraScreen(renderer, 3, 4, 3) self.camScreen.childPositionOffset = [0, 2.5, 0] # Add it to the bot's children self.childrenObjects.append(self.camScreen) ...
如果想不断更新视频流,这里有一个小提示:如果更改了摄像头的纹理(“CameraScreen.cameraVtkTexture”),它将在视频流屏幕上绘制更新后的纹理。您可能需要调用源的 update 方法,让 VTK 知道数据已更改。
LIDAR 流可视化
要添加的最后一个传感器是 LIDAR 流。这与其他项目有点不同——应视为可选附加项——因为它比其他情况更复杂一些。它可以在 scene 子文件夹的“LIDAR.py”中找到。在这种情况下:
- 假设我们将获得一个 2D 点矩阵,每个点表示在特定点上的激光深度测量值。
- 我们必须将此 2D 矩阵从极坐标转换,并将其投影到世界空间中作为半球形深度图。
- 扫描仪将同时测量水平和垂直方向。
- 水平轴从最小角度“minTheta”扫描到最大角度“maxTheta”,中间有“numThetaReadings”次读数。
- 垂直轴从最小角度“minPhi”扫描到最大角度“maxPhi”,中间有“numPhiReadings”次读数。
- 每次深度读数都将进行颜色编码(类似于地形),分别具有最小和最大深度值“minDepth”和“maxDepth”。
- 我们需要将深度值设置为某个初始值,直到我们获得第一次扫描,这称为“initialValue”。
大部分工作在初始化方法中完成,如下所示。它接收这些参数并生成点云数据结构。对于这个模板,我一直在尝试将 2D 测量矩阵转换为可视化半球,因此我提供了一个名为“UpdatePoints()”的方法,该方法接收一个 NumPy 矩阵并将所有点放置在可视化所需的位置。对“LIDAR.py”代码的简要讨论:
- 构造函数初始化了点云使用的数据结构(点、深度值和用于表示数据的单元)。
... def __init__(self, renderer, minTheta, maxTheta, numThetaReadings, minPhi, maxPhi, numPhiReadings, minDepth, maxDepth, initialDepth): ... # Cache these parameters self.numPhiReadings = numPhiReadings self.numThetaReadings = numThetaReadings self.thetaRange = [minTheta, maxTheta] self.phiRange = [minPhi, maxPhi] # Create a point cloud with the data self.vtkPointCloudPoints = vtk.vtkPoints() self.vtkPointCloudDepth = vtk.vtkDoubleArray() self.vtkPointCloudDepth.SetName("DepthArray") self.vtkPointCloudCells = vtk.vtkCellArray() self.vtkPointCloudPolyData = vtk.vtkPolyData() # Set up the structure self.vtkPointCloudPolyData.SetPoints(self.vtkPointCloudPoints) self.vtkPointCloudPolyData.SetVerts(self.vtkPointCloudCells) self.vtkPointCloudPolyData.GetPointData().SetScalars(self.vtkPointCloudDepth) self.vtkPointCloudPolyData.GetPointData().SetActiveScalars("DepthArray")
- 为扫描中的每个“像素”创建具有临时值 [1,1,1] 的点,并将它们推入数据结构。然后调用 update 方法并传入初始深度值以计算测量值正确的实际世界位置。
# Build the initial structure for x in xrange(0, self.numThetaReadings): for y in xrange(0, self.numPhiReadings): # Add the point point = [1, 1, 1] pointId = self.vtkPointCloudPoints.InsertNextPoint(point) self.vtkPointCloudDepth.InsertNextValue(1) self.vtkPointCloudCells.InsertNextCell(1) self.vtkPointCloudCells.InsertCellPoint(pointId) # Use the update method to initialize the points with a NumPy matrix initVals = numpy.ones((numThetaReadings, numPhiReadings)) * initialValue self.UpdatePoints(initVals)
- 请注意,我在这里使用了 Numpy,它是 Python 组件所必需的——我很确定它已经安装好了,但如果不行,请留言。
- 定义了一个法线映射器,并像上面一样将其连接到“SceneObject”actor。它被设置为使用点云的多边形数据(poly data)作为输入。点云结构中的标量值将用于颜色(表示深度),因此为映射器设置范围,以便为每个标量值呈现正确的深度颜色。
# Now build the mapper and actor. mapper = vtk.vtkPolyDataMapper() mapper.SetInput(self.vtkPointCloudPolyData) mapper.SetColorModeToDefault() mapper.SetScalarRange(minDepth, maxDepth) mapper.SetScalarVisibility(1) self.vtkActor.SetMapper(mapper)
- 最后,代码中包含了一个 update 方法的模板。传入一个 2D 深度测量矩阵,它将把测量值转换为世界中的绝对测量值。这是通过将极坐标转换为世界坐标来实现的。
def UpdatePoints(self, points2DNPMatrix): '''Update the points with a 2D array that is numThetaReadings x numPhiReadings containing the depth from the source''' for x in xrange(0, self.numThetaReadings): theta = (self.thetaRange[0] + float(x) * (self.thetaRange[1] - self.thetaRange[0]) / float(self.numThetaReadings)) / 180.0 * 3.14159 for y in xrange(0, self.numPhiReadings): phi = (self.phiRange[0] + float(y) * (self.phiRange[1] - self.phiRange[0]) / float(self.numPhiReadings)) / 180.0 * 3.14159 r = points2DNPMatrix[x, y] # Polar coordinates to Euclidean space point = [r * sin(theta) * cos(phi), r * sin(phi), r * cos(theta) * cos(phi)] pointId = y + x * self.numPhiReadings self.vtkPointCloudPoints.SetPoint(pointId, point) self.vtkPointCloudCells.Modified() self.vtkPointCloudPoints.Modified() self.vtkPointCloudDepth.Modified()
最后,LIDAR 可视化也添加到“Bot.py”中 bot 的子项中。可视化是使用讨论的参数初始化的(这些参数取决于理论扫描仪的规格,此处仅为测试而选择),并将子对象移至机器人头部高度。
... # Create the LIDAR template and set it to the child's offset as well self.lidar = LIDAR.LIDAR(renderer, -90, 90, 180, -22.5, 22.5, 45, 5, 15, 5) self.lidar.childPositionOffset = [0, 2.5, 0] # Add it to the bot's children self.childrenObjects.append(self.lidar)
下一篇文章
这几乎就是本项目 3D 建模部分的全部内容了!下一篇文章将讨论交互和自定义摄像头,以便我们可以将摄像头绑定到第一人称和第三人称的机器人。机器人看到什么,我们就看到什么。