Skip to content

Commit 62da37a

Browse files
authored
Handle inconsistent OpenDRIVE planView lengths (#467)
* Handle inconsistent OpenDRIVE planView lengths * add docstring for makeCurve helper * Add OpenDRIVE parser regression tests
1 parent cae39ca commit 62da37a

2 files changed

Lines changed: 220 additions & 52 deletions

File tree

src/scenic/formats/opendrive/xodr_parser.py

Lines changed: 74 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,52 @@ def point_at(self, s):
212212
return self.rel_to_abs((s, 0, s))
213213

214214

215+
def makeCurve(x0, y0, hdg, length, curve_elem):
216+
"""Create a reference-line curve from an OpenDRIVE geometry element.
217+
218+
The given length is the effective length Scenic should use, which may
219+
differ from the length declared in the map if the planView is inconsistent.
220+
"""
221+
if curve_elem.tag == "line":
222+
return Line(x0, y0, hdg, length)
223+
elif curve_elem.tag == "arc":
224+
# Arc is clothoid of constant curvature.
225+
curv = float(curve_elem.get("curvature"))
226+
return Clothoid(x0, y0, hdg, length, curv, curv)
227+
elif curve_elem.tag == "spiral":
228+
curv0 = float(curve_elem.get("curvStart"))
229+
curv1 = float(curve_elem.get("curvEnd"))
230+
return Clothoid(x0, y0, hdg, length, curv0, curv1)
231+
elif curve_elem.tag == "poly3":
232+
a, b, c, d = (
233+
float(curve_elem.get("a")),
234+
float(curve_elem.get("b")),
235+
float(curve_elem.get("c")),
236+
float(curve_elem.get("d")),
237+
)
238+
return Cubic(x0, y0, hdg, length, a, b, c, d)
239+
elif curve_elem.tag == "paramPoly3":
240+
au, bu, cu, du, av, bv, cv, dv = (
241+
float(curve_elem.get("aU")),
242+
float(curve_elem.get("bU")),
243+
float(curve_elem.get("cU")),
244+
float(curve_elem.get("dU")),
245+
float(curve_elem.get("aV")),
246+
float(curve_elem.get("bV")),
247+
float(curve_elem.get("cV")),
248+
float(curve_elem.get("dV")),
249+
)
250+
p_range = curve_elem.get("pRange")
251+
if p_range and p_range != "normalized":
252+
# TODO support arcLength
253+
raise NotImplementedError("unsupported pRange for paramPoly3")
254+
else:
255+
p_range = 1
256+
return ParamCubic(x0, y0, hdg, length, au, bu, cu, du, av, bv, cv, dv, p_range)
257+
else:
258+
raise NotImplementedError(f'unhandled OpenDRIVE geometry type "{curve_elem.tag}"')
259+
260+
215261
class Lane:
216262
def __init__(self, id_, type_, pred=None, succ=None):
217263
self.id_ = id_
@@ -1434,82 +1480,58 @@ def parse(self, path):
14341480
hdg = float(geom.get("hdg"))
14351481
length = float(geom.get("length"))
14361482
curve_elem = geom[0]
1437-
curve = None
1438-
if curve_elem.tag == "line":
1439-
curve = Line(x0, y0, hdg, length)
1440-
elif curve_elem.tag == "arc":
1441-
# Arc is clothoid of constant curvature.
1442-
curv = float(curve_elem.get("curvature"))
1443-
curve = Clothoid(x0, y0, hdg, length, curv, curv)
1444-
elif curve_elem.tag == "spiral":
1445-
curv0 = float(curve_elem.get("curvStart"))
1446-
curv1 = float(curve_elem.get("curvEnd"))
1447-
curve = Clothoid(x0, y0, hdg, length, curv0, curv1)
1448-
elif curve_elem.tag == "poly3":
1449-
a, b, c, d = (
1450-
cubic_elem.get("a"),
1451-
float(curve_elem.get("b")),
1452-
float(curve_elem.get("c")),
1453-
float(curve_elem.get("d")),
1454-
)
1455-
curve = Cubic(x0, y0, hdg, length, a, b, c, d)
1456-
elif curve_elem.tag == "paramPoly3":
1457-
au, bu, cu, du, av, bv, cv, dv = (
1458-
float(curve_elem.get("aU")),
1459-
float(curve_elem.get("bU")),
1460-
float(curve_elem.get("cU")),
1461-
float(curve_elem.get("dU")),
1462-
float(curve_elem.get("aV")),
1463-
float(curve_elem.get("bV")),
1464-
float(curve_elem.get("cV")),
1465-
float(curve_elem.get("dV")),
1466-
)
1467-
p_range = curve_elem.get("pRange")
1468-
if p_range and p_range != "normalized":
1469-
# TODO support arcLength
1470-
raise NotImplementedError("unsupported pRange for paramPoly3")
1471-
else:
1472-
p_range = 1
1473-
curve = ParamCubic(
1474-
x0, y0, hdg, length, au, bu, cu, du, av, bv, cv, dv, p_range
1475-
)
1476-
curves.append((s0, curve))
1483+
curves.append((s0, x0, y0, hdg, length, curve_elem))
1484+
14771485
if not curves:
14781486
raise ValueError(f"road {road.id_} has an empty planView")
14791487
if not curves[0][0] == 0:
14801488
raise ValueError(
14811489
f"reference line of road {road.id_} does not start at s=0"
14821490
)
1483-
lastS = 0
1484-
lastCurve = curves[0][1]
1491+
1492+
lastS = curves[0][0]
1493+
lastCurve = curves[0]
14851494
refLine = []
1486-
for s0, curve in curves[1:]:
1495+
for curve in curves[1:]:
1496+
s0 = curve[0]
14871497
l = s0 - lastS
1488-
if abs(lastCurve.length - l) > 1e-4:
1489-
raise ValueError(
1490-
f"planView of road {road.id_} has inconsistent length"
1491-
)
1498+
14921499
if l < 0:
14931500
raise ValueError(f"planView of road {road.id_} is not in order")
1494-
elif l < 1e-6:
1501+
1502+
lastS0, x0, y0, hdg, length, curve_elem = lastCurve
1503+
if abs(length - l) > 1e-3:
1504+
warn(
1505+
f"planView of road {road.id_} has inconsistent length: "
1506+
f"geometry at s={lastS0} has declared length {length}, "
1507+
f"but the next geometry starts at s={s0}; "
1508+
f"using length {l}"
1509+
)
1510+
length = l
1511+
1512+
if l < 1e-6:
14951513
warn(
14961514
f"road {road.id_} reference line has a geometry of "
14971515
f"length {l}; skipping it"
14981516
)
14991517
else:
1500-
refLine.append(lastCurve)
1518+
refLine.append(makeCurve(x0, y0, hdg, length, curve_elem))
1519+
15011520
lastS = s0
15021521
lastCurve = curve
1503-
if refLine and lastCurve.length < 1e-6:
1522+
1523+
lastS0, x0, y0, hdg, length, curve_elem = lastCurve
1524+
if refLine and length < 1e-6:
15041525
warn(
15051526
f"road {road.id_} reference line has a geometry of "
1506-
f"length {lastCurve.length}; skipping it"
1527+
f"length {length}; skipping it"
15071528
)
15081529
else:
15091530
# even if the last curve is shorter than the threshold, we'll keep it if
15101531
# it is the only curve; getting rid of the road entirely is handled by
15111532
# road elision above
1512-
refLine.append(lastCurve)
1533+
refLine.append(makeCurve(x0, y0, hdg, length, curve_elem))
1534+
15131535
assert refLine
15141536
road.ref_line = refLine
15151537

tests/formats/opendrive/test_opendrive.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import glob
22
import os
33
from pathlib import Path
4+
import xml.etree.ElementTree as ET
45

56
import matplotlib.pyplot as plt
67
import pytest
78

89
from scenic.core.geometry import TriangulationError
910
from scenic.formats.opendrive import OpenDriveWorkspace
11+
from scenic.formats.opendrive.xodr_parser import (
12+
Cubic,
13+
OpenDriveWarning,
14+
ParamCubic,
15+
RoadMap,
16+
makeCurve,
17+
)
1018

1119
oldDir = os.getcwd()
1220
os.chdir(Path("tests") / "formats" / "opendrive")
@@ -30,3 +38,141 @@ def test_map(path, runLocally, pytestconfig):
3038
odw.show(plt)
3139
plt.show(block=False)
3240
plt.close()
41+
42+
43+
def write_xodr(tmp_path, plan_view):
44+
path = tmp_path / "test.xodr"
45+
path.write_text(
46+
f"""<?xml version="1.0" encoding="UTF-8"?>
47+
<OpenDRIVE>
48+
<road name="Road 7" length="20.0" id="7" junction="-1">
49+
{plan_view}
50+
<lanes>
51+
<laneOffset s="0.0" a="0.0" b="0.0" c="0.0" d="0.0"/>
52+
<laneSection s="0.0">
53+
<center>
54+
<lane id="0" type="none" level="false"/>
55+
</center>
56+
<right>
57+
<lane id="-1" type="driving" level="false">
58+
<width sOffset="0.0" a="3.5" b="0.0" c="0.0" d="0.0"/>
59+
</lane>
60+
</right>
61+
</laneSection>
62+
</lanes>
63+
</road>
64+
</OpenDRIVE>
65+
"""
66+
)
67+
return path
68+
69+
70+
def test_inconsistent_planview_length_warns(tmp_path):
71+
path = write_xodr(
72+
tmp_path,
73+
"""<planView>
74+
<geometry s="0.0" x="0.0" y="0.0" hdg="0.0" length="12.0">
75+
<line/>
76+
</geometry>
77+
<geometry s="10.0" x="10.0" y="0.0" hdg="0.0" length="10.0">
78+
<line/>
79+
</geometry>
80+
</planView>""",
81+
)
82+
83+
road_map = RoadMap()
84+
85+
with pytest.warns(
86+
OpenDriveWarning,
87+
match="planView of road 7 has inconsistent length",
88+
):
89+
road_map.parse(path)
90+
91+
road = road_map.roads[7]
92+
assert len(road.ref_line) == 2
93+
assert road.ref_line[0].length == pytest.approx(10.0)
94+
assert road.ref_line[1].length == pytest.approx(10.0)
95+
96+
97+
def test_empty_planview_rejected(tmp_path):
98+
path = write_xodr(tmp_path, "<planView/>")
99+
100+
road_map = RoadMap()
101+
102+
with pytest.raises(ValueError, match="road 7 has an empty planView"):
103+
road_map.parse(path)
104+
105+
106+
def test_planview_must_start_at_zero(tmp_path):
107+
path = write_xodr(
108+
tmp_path,
109+
"""<planView>
110+
<geometry s="1.0" x="0.0" y="0.0" hdg="0.0" length="10.0">
111+
<line/>
112+
</geometry>
113+
</planView>""",
114+
)
115+
116+
road_map = RoadMap()
117+
118+
with pytest.raises(
119+
ValueError, match="reference line of road 7 does not start at s=0"
120+
):
121+
road_map.parse(path)
122+
123+
124+
def test_planview_must_be_in_order(tmp_path):
125+
path = write_xodr(
126+
tmp_path,
127+
"""<planView>
128+
<geometry s="0.0" x="0.0" y="0.0" hdg="0.0" length="10.0">
129+
<line/>
130+
</geometry>
131+
<geometry s="-1.0" x="10.0" y="0.0" hdg="0.0" length="10.0">
132+
<line/>
133+
</geometry>
134+
</planView>""",
135+
)
136+
137+
road_map = RoadMap()
138+
139+
with pytest.raises(ValueError, match="planView of road 7 is not in order"):
140+
road_map.parse(path)
141+
142+
143+
def test_make_curve_poly3():
144+
curve_elem = ET.fromstring('<poly3 a="0.0" b="1.0" c="0.0" d="0.0"/>')
145+
146+
curve = makeCurve(0.0, 0.0, 0.0, 10.0, curve_elem)
147+
148+
assert isinstance(curve, Cubic)
149+
assert curve.length == pytest.approx(10.0)
150+
151+
152+
def test_make_curve_param_poly3():
153+
curve_elem = ET.fromstring(
154+
'<paramPoly3 aU="0.0" bU="1.0" cU="0.0" dU="0.0" '
155+
'aV="0.0" bV="0.0" cV="0.0" dV="0.0" pRange="normalized"/>'
156+
)
157+
158+
curve = makeCurve(0.0, 0.0, 0.0, 10.0, curve_elem)
159+
160+
assert isinstance(curve, ParamCubic)
161+
assert curve.length == pytest.approx(10.0)
162+
163+
164+
def test_make_curve_param_poly3_rejects_arc_length():
165+
curve_elem = ET.fromstring(
166+
'<paramPoly3 aU="0.0" bU="1.0" cU="0.0" dU="0.0" '
167+
'aV="0.0" bV="0.0" cV="0.0" dV="0.0" pRange="arcLength"/>'
168+
)
169+
170+
with pytest.raises(NotImplementedError, match="unsupported pRange for paramPoly3"):
171+
makeCurve(0.0, 0.0, 0.0, 10.0, curve_elem)
172+
173+
174+
def test_make_curve_rejects_unknown_geometry_type():
175+
curve_elem = ET.fromstring("<unknown/>")
176+
177+
with pytest.raises(NotImplementedError, match="unhandled OpenDRIVE geometry type"):
178+
makeCurve(0.0, 0.0, 0.0, 10.0, curve_elem)

0 commit comments

Comments
 (0)