|
目标与效果
根据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[&#39;Width&#39;])
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
|
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|