前言

参照 open3d 中的交互式点云可视化,我们可以理解其是如何进行框选点云的。
其实现代码很简单,我们直接调用接口即可:

pcd = o3d.io.read_point_cloud('../data/cropped_1.ply')
o3d.visualization.draw_geometries_with_editing([pcd])

可以看到,要想进行点云框选,首先要锁定视角,随后便可以在上面进行选择了。

点云标注工具开发记录(三)之框选点云与渲染颜色_1024程序员节

点云框选与渲染

实现思路:

根据 open3d 的实现流程,我们首先需要进行视角锁定,随后,将所有的点云映射到2D空间,然后判断点云是否在2D框构成的封闭框内。这里主要用到的是containsPoint函数:

点云标注工具开发记录(三)之框选点云与渲染颜色_1024程序员节_02

那么,首先是要锁定视角(相机),因为我们要获取当前屏幕坐标与点云坐标的映射,代码如下:

监听鼠标事件

opengl_widget.py文件

#mousePressEvent是PyQt的鼠标事件,我们重写了它
def mousePressEvent(self, event: QtGui.QMouseEvent):
		#获取点击的屏幕坐标
        self.lastX, self.lastY = event.pos().x(), event.pos().y()  #379,405
        #处理事件前,判断是否在锁定视角状态
        if self.mode == MODE.DRAW_MODE:
        	#判断如果是左键鼠标事件:
            if event.button() == QtCore.Qt.MouseButton.LeftButton:
            	#获取相对中心点坐标,这里的self.width()、self.height()是框体宽高
                x, y = event.pos().x() - self.width() / 2, self.height() / 2 - event.pos().y()
                #坐标乘以相应的缩放系数,这个是Qt窗口的缩放
                x = x * self.ortho_change_scale
                y = y * self.ortho_change_scale
				#求出的值x,y即点云的x,y位置,而z则设置10000,这是一个很大的值,这里理解可以参考深度图
                if not self.polygon_vertices:
                    self.polygon_vertices = [[x, y, 10000], [x, y, 10000], [x, y, 10000]]
                else:
                    self.polygon_vertices.insert(-1, [x, y, 10000])
            #若点击了鼠标右键,说明要框选完成,则调用显示类别选择框
            elif event.button() == QtCore.Qt.MouseButton.RightButton:
                # 选择类别与group
                self.mainwindow.category_choice_dialog.load_cfg()
                self.mainwindow.category_choice_dialog.show()
        else:
            #若不是在绘图模式,则不显示画圈
            self.show_circle = True
            if event.button() == QtCore.Qt.MouseButton.LeftButton:
                self.mouse_left_button_pressed = True
            elif event.button() == QtCore.Qt.MouseButton.RightButton:
                self.mouse_right_button_pressed = True
        self.update()

配置类别配置

widgets\category_choice_dialog.py

进行类别选择的配置加载

def load_cfg(self):
        self.listWidget.clear()

        labels = self.mainwindow.cfg.get('label', [])

        for label in labels:
            name = label.get('name', '__unclassified__')
            color = label.get('color', '#ffffff')
            item = QtWidgets.QListWidgetItem()
            item.setSizeHint(QtCore.QSize(200, 30))
            widget = QtWidgets.QWidget()

            layout = QtWidgets.QHBoxLayout()
            layout.setContentsMargins(9, 1, 9, 1)
            label_category = QtWidgets.QLabel()
            label_category.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
            label_category.setText(name)
            label_category.setObjectName('label_category')

            label_color = QtWidgets.QLabel()
            label_color.setFixedWidth(10)
            label_color.setStyleSheet("background-color: {};".format(color))
            label_color.setObjectName('label_color')

            layout.addWidget(label_color)
            layout.addWidget(label_category)
            widget.setLayout(layout)

            self.listWidget.addItem(item)
            self.listWidget.setItemWidget(item, widget)

        self.lineEdit_group.clear()
        self.lineEdit_category.clear()

        if self.listWidget.count() == 0:
            QtWidgets.QMessageBox.warning(self, 'Warning', 'Please set categorys before tagging.')

设置类型选择控件

显示类别选择部件,然后我们点击类别分类:

点云标注工具开发记录(三)之框选点云与渲染颜色_点云_03

通过在类别选择框上绑定选择事件,获取我们点击的类别:

self.listWidget.itemClicked.connect(self.get_category)

def get_category(self, item):#item是点击的属性
		#列表控件
        widget = self.listWidget.itemWidget(item)
        #根据属性名查询列表控件,得到点击的控件所对应的名字
        label_category = widget.findChild(QtWidgets.QLabel, 'label_category')
        self.lineEdit_category.setText(label_category.text())#label_category.text()值为杆塔
        #将类别属性写入lineEdit_category
        self.lineEdit_category.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)

类别设置事件

执行完该步骤后,类别选择完成:

点云标注工具开发记录(三)之框选点云与渲染颜色_Qt_04

随后,点击apply按钮,触发apply事件,该事件将进行所框选点云的颜色

def apply(self):
        category = self.lineEdit_category.text()
        group = self.lineEdit_group.text()
        if not category:
            QtWidgets.QMessageBox.warning(self, 'Warning', 'Please select one category before submitting.')
            return

        group = int(group) if group else None
        print('category: ', category)
        print('group: ', group)
        #执行框选点云的属性改变
        self.mainwindow.openGLWidget.polygon_pick(category, group)
        # self.category_instance.emit(category, group)
		#重新渲染
        self.mainwindow.openGLWidget.change_mode_to_view()
        self.close()

框选点云渲染

接下来便是重头戏了,如何改变点云的属性,框选的点云坐标框如下:

点云标注工具开发记录(三)之框选点云与渲染颜色_计算机视觉_05

def polygon_pick(self, category:int=None, instance:int=None):
		#获取映射的点云坐标,共有7个值,其中第一个点的值有3个,即总共点了5个点
        polygon_vertices = self.polygon_vertices
        """
        x, y = event.pos().x() - self.width() / 2, self.height() / 2 - event.pos().y()
                x = x * self.ortho_change_scale
                y = y * self.ortho_change_scale
        """
        #转换回原本的屏幕坐标
        for p in polygon_vertices:
            p[0] = p[0] / self.ortho_change_scale + self.width() / 2
            p[1] = self.height() / 2 - p[1] / self.ortho_change_scale
        polygon_vertices = [QPointF(p[0], p[1]) for p in polygon_vertices]
        polygon = QPolygonF(polygon_vertices)
        #polygon.boundingRect() 这个函数或方法通常用于获取一个多边形(polygon)的外接矩形(bounding rectangle)
        rect = polygon.boundingRect()#得到的rect值为:PyQt5.QtCore.QRectF(548.0, 241.0, 73.0, 60.0)
		
		#将点云映射到2D空间  得到的数据维度为:(1178312, 2)
        vertices2D = self.vertices_to_2D()
        #形成的左右上下框的位置
        l, r, t, b = rect.x(), rect.x() + rect.width(), rect.y(), rect.y() + rect.height()
		#计算出蒙版,即在 这个范围内的点云
		#(1178312,)值为[False False False False False ...]
        mask1 = (l < vertices2D[:, 0]) & (vertices2D[:, 0] < r) & \
               (t < vertices2D[:, 1]) & (vertices2D[:, 1] < b)
        print('mask1: ', sum(mask1))
        #polygon.containsPoint 这个表达通常用于计算几何学中,尤其是在地理信息系统(GIS)、计算机图形学或者游戏开发中,用来判断一个点是否位于一个多边形内部,polygon为封闭图形
        #np.array(polygon)查看其内值,这个坐标是相对于窗口的
		Out[4]: 
		array([PyQt5.QtCore.QPointF(576.0, 244.0),
		       PyQt5.QtCore.QPointF(548.0, 267.0),
		       PyQt5.QtCore.QPointF(573.0, 297.0),
		       PyQt5.QtCore.QPointF(611.0, 301.0),
		       PyQt5.QtCore.QPointF(621.0, 242.0),
		       PyQt5.QtCore.QPointF(613.0, 241.0),
		       PyQt5.QtCore.QPointF(576.0, 244.0)], dtype=object)
		       #QtCore.Qt.FillRule.WindingFill 是 Qt 框架中用于图形绘制的一个枚举值,特别是在处理路径(Path)填充时。它定义了如何确定一个点是否位于路径内部,以决定该点是否应该被填充
        mask2 = [polygon.containsPoint(QPointF(p[0], p[1]), QtCore.Qt.FillRule.WindingFill) for p in vertices2D[mask1]]
        #上述代码的含义是:筛选vertices2D中在矩形区域内的点,同时这些值还在polygon中

        print('mask2: ', sum(mask2))
        #将mask2值赋给mask1,在mask1为true的情况下,这也就以为这是进行了一个更精细的筛选
        mask1[mask1 == True] = mask2
        #可以看到,self.mask[self.mask==False]
		#Out[9]: array([], dtype=bool)self.mask的值全为true
        mask = self.mask.copy()
        mask[mask==True] = mask1
        if instance is not None:
            self.instances[mask] = instance
        #获取类别索引,将对应位置的点云类别设置为该索引,self.categorys的为ndarray数组,维度为1178312
        if category is not None:
            index = list(self.mainwindow.category_color_dict.keys()).index(category)#比如此时类别为杆塔,索引为1
            self.categorys[mask] = index

        #
        self.mask.fill(True)
        self.category_display_state_dict = {}
        self.instance_display_state_dict = {}
		#根据当前的状态进行对应的状态展示
        self.mainwindow.save_state = False
        if self.display == DISPLAY.ELEVATION:
            self.change_color_to_category()
        elif self.display == DISPLAY.RGB:
            self.change_color_to_category()
        elif self.display == DISPLAY.CATEGORY:
            self.change_color_to_category()
        elif self.display == DISPLAY.INSTANCE:
            self.change_color_to_category()

其中,如何将点云坐标映射为2D坐标是最重要的:

def vertices_to_2D(self):
        if self.pointcloud is None:
            return
        # 转numpy便于计算
        projection = np.array(self.projection.data()).reshape(4, 4)
        camera = np.array(self.camera.toMatrix().data()).reshape(4, 4)
        vertex_transform = np.array(self.vertex_transform.toMatrix().data()).reshape(4, 4)
        # 添加维度,self.current_vertices是点云坐标(178342424,3)
        vertexs = np.hstack((self.current_vertices, np.ones(shape=(self.current_vertices.shape[0], 1))))
        vertexs2model = vertexs.dot(vertex_transform.dot(camera))
        vertexs2projection = vertexs2model.dot(projection)
        # 转换到屏幕坐标
        xys = vertexs2projection[:, :2]
        xys = xys + np.array((1, -1))
        xys = xys * np.array((self.width() / 2, self.height() / 2)) + 1.0
        xys = xys * np.array((1, -1))
        return xys

准备变换矩阵:
projection = np.array(self.projection.data()).reshape(4, 4):获取投影矩阵,并将其转换为4x4的NumPy数组。投影矩阵用于将3D坐标转换为2D坐标。

camera = np.array(self.camera.toMatrix().data()).reshape(4, 4):获取相机矩阵(视图矩阵),并将其转换为4x4的NumPy数组。相机矩阵定义了相机的位置和朝向。

vertex_transform = np.array(self.vertex_transform.toMatrix().data()).reshape(4, 4):获取顶点变换矩阵,并将其转换为4x4的NumPy数组。这个矩阵可能用于对顶点进行额外的变换,如缩放、旋转或平移。

准备顶点数据:
vertexs = np.hstack((self.current_vertices, np.ones(shape=(self.current_vertices.shape[0], 1)))):将当前的顶点坐标(self.current_vertices)与一列全为1的向量堆叠起来,以形成齐次坐标。这是为了进行矩阵变换而准备的。
应用变换:
vertexs2model = vertexs.dot(vertex_transform.dot(camera)):首先,将顶点变换矩阵与相机矩阵相乘,然后将结果矩阵与顶点坐标相乘,得到模型空间到相机空间的变换后的顶点坐标。
vertexs2projection = vertexs2model.dot(projection):接着,将投影矩阵与相机空间中的顶点坐标相乘,得到裁剪空间中的顶点坐标。
转换到屏幕坐标:
xys = vertexs2projection[:, :2]:从裁剪空间中的顶点坐标中提取x和y坐标(忽略z坐标和齐次坐标分量)。
xys = xys + np.array((1, -1)):对x和y坐标进行偏移,这可能是为了调整坐标系的原点或进行某种标准化。
xys = xys * np.array((self.width() / 2, self.height() / 2)) + 1.0:将x和y坐标缩放到屏幕尺寸,并加上一个偏移量(1.0),这可能是为了将坐标映射到屏幕上的某个区域。
xys = xys * np.array((1, -1)):再次对y坐标进行翻转(乘以-1),这通常是为了将屏幕坐标系从OpenGL的左手系转换为右手系或反之。
返回结果:
return xys:返回转换后的2D屏幕坐标。

点云颜色更新

change_color_to_category方法是根据当前的模式来进行渲染,修改当前的点云,如加载当前的点云,是以mask的形式展示的,当然这里是全为True,随后,进行颜色更新

def change_color_to_category(self):
        if self.pointcloud is None:
            return
        self.category_color_update()
        self.current_vertices = self.pointcloud.xyz[self.mask]
        self.current_colors = self.category_color[self.mask]
        self.display = DISPLAY.CATEGORY

        self.init_vertex_vao()
        self.update()
        self.mainwindow.update_dock()

下面的代码即进行点云颜色的修改,其会读取类别的颜色

def category_color_update(self):
        if self.categorys is None:
            return
        self.category_color = np.zeros(self.pointcloud.xyz.shape, dtype=np.float32)
        for id, (category, color) in enumerate(self.parent().category_color_dict.items()):
            color = QtGui.QColor(color)
            self.category_color[self.categorys==id] = (color.redF(), color.greenF(), color.blueF())

至此,我们便完成了点云框选与颜色渲染部分。