找回密码
 立即注册
查看: 274|回复: 0

[实验][PCG]使用Shapely计算路口边缘

[复制链接]
发表于 2022-4-24 15:28 | 显示全部楼层 |阅读模式
目标与效果

根据Landscape Spline曲线,计算并自动放置预制好的Spline Mesh Blueprint Actor。并且配置正确参数。

Shapely计算路口边缘
https://www.zhihu.com/video/1497542024220315648



生成前



生成后



可同时处理多个交叉口,以及多条路的交口



使用Matplotlib进行初步的Debug

关于Python和Shapely

目前,在Unreal里直接使用Houdini Engine会面临着配置参数繁琐,计算时间长,自动化程度低等等问题。而Unreal蓝图又缺乏比较方便的计算几何库。
UE4.27后已经开始支持Python3。Unreal Python可以调用所有暴露给蓝图的方法,同时拥有庞大的第三方库。因此我想尝试使用Python来处理部分PCG生成的工作。
Shapely[1]是Python的第三方计算几何库,虽然面向的是GIS等地理的计算方法,但他的功能强大且丰富的点线面计算方法都是通用方法,依然可以应用在通用几何计算中。
Shapely目前还只能处理2维模型,但可以保留Z轴。但很多方法中Z轴参数并不参与计算。而本例中也无需考虑立交桥等立体交通形式,因此该限制反而有利于减少我处理曲线坐标的难度。如果你需要考虑Z轴(高度),那请谨慎采纳本篇文章的方法与观点。令人欣喜的是,在Shapely2.0(还未发布)中,会考虑三维空间的计算。
需要用到的库


  • shapely :主要的计算几何库,提供基础功能
  • numpy : 用于坐标系间的计算的数学库
  • matplotlib:用于图形化数据的debug库,正式应用时,可以去除。
  • itertools :用于数据分类的库
  • unreal:与Unreal内部数据进行交互
处理思路与对象

UE4中Spline Mesh需要至少两个顶点位置以及他们的切线方向才能获得弧形效果。如下图:



边缘由一个Spline Mesh来构成,起始点和终点的位置以及他们的切线控制了形态。



计算过程

整个计算过程比较简单:
第一步——将所有道路的交点找到
第二步——曲线有两个以上交点时,需要从两个相邻交点的中间进行分割曲线
第三步——将交点以及通过该交点的曲线进行打组并单独处理
第四步——在上一步所得的每个组中,对曲线的边线进行求交,并从交点处向外延找到位置 。然后计算切线 。以上四个值是我们最终想要的结果。
不难看出上面第四步会重复第一到第三步中的大部分操作。因此关于求交点等方法可以作为全局方法。
我创建了三个类来分别代表不同层级的对象:
RoadLine :用于保存道路曲线以及附带的属性。作为我们最基础的数据结构。
EdgeSpline: 作为最小的生成单元。一个交叉路口至少包含2个EdgeSpline实例。每个实例代表两条相交马路的一边。
CrossArea :作为计算过程第三步中的交点与过该交点曲线的一个集合。
代码分析

整体过程

主要步骤直接写在了主函数main中:
def main():
    """ gen road edge script """
    ''' 加载Json文件,保存了引擎中Spline的点信息 '''
    filters = {'Width': 500.0, 'SplineType': 1}
    points_json = load_spline_json('roadmap_segment.json', **filters)  # read the roadmap json file

    '''生成原始曲线,合并相邻曲线。'''
    lines = linemerge(shape(points_json))

    '''查找交叉点,并合并属于同一个交叉口的交叉点'''
    cross_result = merge_intersections(get_intersections(lines), 700.0)

    '''创建我们自定义的类RoadLine实例,并保存经过本线段上的交点'''
    road_lines = []
    for i, line in enumerate(lines.geoms):
        roadline = RoadLine.get_properties_from_json(line, points_json, i)
        roadline.update_intersections_attribute(cross_result, 700.0)
        if len(roadline.intersections) > 0:
            # 如果一条曲线有交点,便保存近数组进行进一步处理
            road_lines.append(roadline)

    '''绘制原始曲线和交点'''
    for line in lines.geoms:
        plt.plot(line.xy[0], line.xy[1])
    for pt in cross_result:
        plt.scatter(pt.x, pt.y)

    '''创建交叉口类CrossArea,初始化时将会计算我们想要的结果'''
    cross_area_list = []
    for cp in cross_result:
        cross_area = CrossArea(cp, road_lines, filters)
        cross_area_list.append(cross_area)

    '''获得最后结果'''
    property_content = []
    for ca in cross_area_list:
        property_content += ca.get_spline_properties()
        ca.draw() # 画中心原始线段和交点
        ca.draw_edge_only() # 绘制边缘线,以及控制点

    '''打印结果用于检查'''
    # for prop in property_content:
    #     print(prop)
    '''输出所绘制的曲线'''
    plt.axis("equal")
    plt.show()

    '''将结果写入Json'''
    content = {}
    with open('output_cross_splines.json', 'w') as pf:
        content['spline'] = property_content
        json.dump(content, pf)从代码注释中可以看到整个计算的脉络。但是在测试过程中,会出现很多细节上的问题,下面会一一进行探讨:
细节处理

shapely判断点是否在线段上时会因为数据精度偏差而失败

我发现,在得到两条曲线的交点后,再判断该交点是否在曲线上(touch方法)时,有时会给出否的结果。我想应该和数据精度相关,因此我采用了自己的判断方法:
def get_touch_lines(point, lines, tolerance=1.0):
    _touch_lines = []
    if isinstance(lines, MultiLineString):
        for line in lines.geoms:
            dist = point.distance(line)
            if dist < tolerance:
                _touch_lines.append(line)
    else:
        for road in lines:
            dist = point.distance(road.line)
            if dist < tolerance:
                _touch_lines.append(road)
    return _touch_lines通过判断点到线段的距离来判断点是否在曲线上。
当多条曲线相交于一个交叉路口时,其实会产生多个交点

由于人工摆放的曲线,无法精确到多个曲线从一点通过,因此必然会出现多个交点的问题。该问题只能进行一次交点的距离判断,当两个交点低于道路宽度的一半时,就应该进行merge操作。
def merge_intersections(point_list, tolerance=700.0):
    result = []
    for i in range(len(point_list) - 1):
        current = point_list.pop()
        rest = MultiPoint(point_list)
        p0, p1 = nearest_points(current, rest)
        if p0.distance(p1) > tolerance:
            result.append(current)
    result += point_list
    return result同时,我也会在CrossArea类中重新更新交点的位置,使其处在所有交点的质心位置:
class CrossArea:
    ···
    def cross_road(self, line_list, filters):
        ···
         if len(result) > 2:
            inters = get_intersections([road.line for road in result])
            if len(inters) > 1:
                points = MultiPoint(inters)
                centroid_coords = [points.centroid.x, points.centroid.y, self.__cross.z] # 将所有交点的质点返回
                self.__cross = Point(centroid_coords)
        return result如何正确的切割曲线



像上图中,所有曲线都会在交点间进行切断,这样做的好处有很多,避免了求边缘交点时与两外的交叉口进行错误的相交计算。切割曲线的方法如下:



在两个交点的中间位置进行切割曲线

假设曲线上有三个交点,那么两端的交点处理方式一致,而中间的交点需要求两个cut点:
def cut_mid(line, start, end):
    if start > end:
        start, end = end, start
    coords = list(line.coords)
    line_pts = []
    start_pt = line.interpolate(start)
    end_pt = line.interpolate(end)
    line_pts.append(start_pt)
    for i, p in enumerate(coords):
        pd = line.project(Point(p))
        if start <= pd <= end:
            line_pts.append(Point(p))
    line_pts.append(end_pt)
    return LineString(line_pts)

class CrossArea:
    ···
    def cross_road(self, line_list, filters):
        roads = get_touch_lines(self.__cross, line_list, filters['Width'])
        result = []
        for road in roads:
            dist = road.line.project(self.__cross)
            intersections = road.intersections
            if len(intersections) > 1:
                # when the road has more than one intersection
                sorted_inters = sorted(intersections, key=lambda x: road.line.project(x))
                total = len(sorted_inters)
                i = sorted_inters.index(self.__cross)
                if 0 < i < total-1: # 中间的点
                    start = (road.line.project(sorted_inters[i-1]) + dist) * 0.5
                    end = (road.line.project(sorted_inters[i+1]) + dist) * 0.5
                    result.append(RoadLine(cut_mid(road.line, start, end), road))
                elif i == 0: # 最左边的点
                    start = 0
                    end = (road.line.project(sorted_inters[i+1]) + dist) * 0.5
                    result.append(RoadLine(cut_mid(road.line, start, end), road))
                elif i == total-1: # 最右边的点
                    start = (road.line.project(sorted_inters[i-1]) + dist) * 0.5
                    end = road.line.length
                    result.append(RoadLine(cut_mid(road.line, start, end), road))
            else:
                # when the road has only one intersection
                result.append(road)
        ···
        return result多条曲线相交时,如何获得外围交点




所有交点

如上图所示,当超过两条曲线交汇时,代表道路的边缘曲线会有过多的交点,而只有部分交点是我们需要的:



最终想要的结果

通过上图我们可以发现,其实我们需要的是最外围的交点。可以采用凸包和位置判断来获得最外围交点:



利用凸包和位置判断最后的交点

class CrossArea:
    ···
    def get_edge_inters(self):
        inters = get_intersections([edge.line for edge in self.__edges])
        multi_points = MultiPoint(inters)
        convex = multi_points.convex_hull # 获得所有点的凸包
        result = filter(convex.touches, inters) # 通过点在凸包位置进行过滤
        return [r for r in result]
    ···Spline Mesh的切线方向问题

由于Unreal的Spline切线参数是有方向的,如果方向不对,会使一端的面片方向反转。同时Spline Mesh有左右之分,只能让Spline Mesh向左弯折才能和道路边缘匹配。因此我们需要知道曲线的方向。
方法是从交点切割边缘线段(下图绿线),然后与主线(下图红线)求交,若相交就删除。



通过中心道路曲线与边缘曲线是否有交点,就可以过滤出保留的边缘曲线

def cut(line, distance):
    # Cuts a line in two at a distance from its starting point
    if distance <= 0.0 or distance >= line.length:
        return [LineString(line)]
    coords = list(line.coords)
    for i, p in enumerate(coords):
        pd = line.project(Point(p))
        if pd == distance:
            return [
                LineString(coords[:i + 1]),
                LineString(coords[i:])]
        if pd > distance:
            cp = line.interpolate(distance)
            if cp.has_z:
                return [
                    LineString(coords[:i] + [(cp.x, cp.y, cp.z)]),
                    LineString([(cp.x, cp.y, cp.z)] + coords[i:])]
            else:
                return [
                    LineString(coords[:i] + [(cp.x, cp.y)]),
                    LineString([(cp.x, cp.y)] + coords[i:])]

class EdgeSpline:
    ···
    def get_straight_roads(self):
        straight_lines = []
        for edge in self.edges:
            dis = edge.line.project(self.inter)
            result = cut(edge.line, dis)
            if len(result) != 2:
                return None
            line1, line2 = result[0], result[1]
            # 如果当前的曲线和中心线有交点,就排除该曲线
            multiroads = MultiLineString([road.line for road in self.roads])
            if line1.intersects(multiroads):
                if line2.intersects(multiroads):
                    # 当道路为环形时,可能会有两次相交,此处处理此种特殊情况(之前在两个交点之间切断曲线已经避免了此类情况的产生)
                    sub_inter1 = line1.intersection(multiroads)
                    if self.inter.buffer(self.roads[0].width * 2.0).contains(sub_inter1):
                        straight_lines.append(RoadLine(line2, edge))
                    else:
                        straight_lines.append(RoadLine(line1, edge))
                else:
                    straight_lines.append(RoadLine(line2, edge))
            else:
                straight_lines.append(RoadLine(line1, edge))

        return straight_lines当将外围的曲线保留之后,通过在两条曲线上各取一点,然后根据顺时针对点进行排序处理:



计算起点和终点的位置和切线方向

class EdgeSpline:
    ···
    def calc_properties(self):
        points = []
        if self.straights is None:
            return None
        for road in self.straights:
            line, width = road.line, road.width
            dist = line.project(self.inter)
            if dist < 1.0:
                end = line.interpolate(width)
            else:
                end = line.interpolate(dist - width)
            points.append(end)

        if len(points) == 2:
            # be sure they are counter-clockwise order
            points.insert(1, self.inter)
            if LinearRing(points).is_ccw:
                points.reverse()
            start_loc = np.array(points[0].xy)
            end_loc = np.array(points[2].xy)
            start_tan = (np.array(self.inter.coords.xy) - start_loc) * 2.0
            end_tan = (end_loc - np.array(self.inter.coords.xy)) * 2.0
            self.set_properties(start_loc, start_tan, end_loc, end_tan)
        return points
    ···蓝图参数设置

如果输出的参数在蓝图的构造函数中直接赋值给Spline Mesh Component,会导致蓝图的Spline无法在视窗交互调整。因此我们需要创建一个Custom Event来帮我们赋值:



左边没有链接的节点是废除的




总结

本文主要是实验仅使用Python的情况下能否完成道路的交叉口计算和摆放。由于只是一次简单的尝试,因此代码中缺乏很多报错处理,主要精力都放到了具体业务上。然而通过测试,发现该工具在处理小角度相融合的两条路时,会计算错误。同时没有考虑高度的完全比配,所以必须配合VT使用。
另外使用Python处理模型存在Debug困难的问题。但是计算速度以及便捷性要远胜于Houdini Engine。
Shapely库处理曲线和点云尚可,现无法处理三维模型(未来shapely2.0肯能会提供相应的功能)。scikit-geometry库将是一个不错的选择,它是基于著名的CGAL库的python封装,但由于还只是早期的公开库,因此还需等待一段时间才会功能完善。
另外由于我本人参与的项目引擎是4.25,Unreal的python还是2.7版本,因此无法安装shapely库。所以只能将python3封装成exe,然后在python2.7中调用,这看起来就很不靠谱,但依然能够跑通。即使在这样的环境下,计算速度依然好过Houdini Engine。
这是我第一次使用python来处理曲线的相关问题,很难保证该方法的普遍性,如果你有什么疑问,我可能无法给你满意的答复。
不建议直接使用在项目中,功能尚待完善。本例中也不会考虑处理交叉口中心区域。
若文章有错误或不妥之处,望指正,感激!另外,如果你对Python在PCG中的应用很感兴趣或已有经验,非常欢迎与你建立联系和交流。



交叉口中心地带并没有处理

文件下载

python 3.9
需要安装shapely,numpy ,itertools ,matplotlib库。
python file:
https://github.com/Liuzkai/PythonScript/blob/master/GeometryPy/gen_road_cross_edge.py
测试json:
https://github.com/Liuzkai/PythonScript/blob/master/GeometryPy/roadmap_segment.json
生成json是项目中已开发的功能,不在本文中讨论。你可根据模板Json生成相应的Json点云信息即可。
参考


  • ^The Shapely User Manualhttps://shapely.readthedocs.io/en/stable/manual.html#efficient-rectangle-clipping

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-9-22 13:45 , Processed in 0.067589 second(s), 23 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表