fix(extract): Markierungen folgen der Kante und stürzen nicht am Zickzack ab
Befund 3: In junction_marks() kam die Richtung einer Zufahrt aus origin -> points[1], also nur aus dem ersten Segment der Kante. Haltelinie und Zebrastreifen wurden aber bis reach + STOP_SETBACK + STOP_DEPTH (bis 16 m) auf dieser Geraden aufgespannt - knickte die Kante innerhalb dieser Strecke ab, landete die Markierung daneben statt darauf. Am echten Netz (gemessen über alle 144 committeten Chunks): 452,65 m² von 43.906 m² Markierungsfläche (1,03 %) liegen außerhalb der Fahrbahn, davon 291,44 m² Zebra und 147,68 m² Haltelinie; die schlimmsten Einzelfälle sind vollständige Haltelinien mit 5,41 m². Die Richtung kommt jetzt aus line.interpolate(min(reach, länge)) - dem tatsächlichen Punkt auf der Kante bei Streckenlänge reach - statt aus dem zweiten Stützpunkt. Befund 4: offset_curve() liefert bei einem engen Zickzack eine MultiLineString statt einer LineString; substring() in _dashed() akzeptiert nur LineString und wirft dann GeometryTypeError. Am heutigen Mosaik tritt das nicht auf, aber eine geänderte Korrektur oder eine eingehängte Kante mit engem Knick hätte den Vier-Minuten-Lauf abgebrochen. Zweiter, stiller Ast: Eine sehr spitze Kehre lässt offset_curve() leer zurückkommen; _solid() puffert das ungeprüft zu einem Nullflächen-Polygon. _offset() normalisiert jetzt auf eine Liste von LineStrings (leer -> [], MultiLineString -> ihre geoms), _dashed() und _solid() iterieren darüber; longitudinal() flacht die jetzt listenwertigen _solid()-Aufrufe ab. Beide Befunde mit Tests, die ohne die jeweilige Korrektur rot sind: der Zickzack-Fall bricht am unkorrigierten Code mit GeometryTypeError ab, der Kehre-Fall lieferte zwei Nullflächen-Polygone unter den Rand-/ Doppellinien.
This commit is contained in:
1 parent
ceb23bf912
commit
83140f89e1
3 files changed
+116
-19
No files matched your search
@@ -40,41 +40,58 @@ def longitudinal(graph: Graph) -> list[tuple[Polygon, str]]:
|
||||
# Linie ist WIDTH breit (Pufferung um WIDTH/2 je Seite), sodass von den 20 cm genau
|
||||
# WIDTH = 10 cm als Lücke zwischen den gemalten Linien übrig bleiben - das ist der im
|
||||
# Entwurf verlangte Abstand, nicht der Achsabstand selbst.
|
||||
out += [(_solid(line, off), "gelb_doppelt") for off in (-WIDTH, WIDTH)]
|
||||
out += [(p, "gelb_doppelt") for off in (-WIDTH, WIDTH) for p in _solid(line, off)]
|
||||
rand = section.carriageway / 2 - section.parking
|
||||
out += [(_solid(line, off), "weiss_rand") for off in (-rand, rand)]
|
||||
out += [(p, "weiss_rand") for off in (-rand, rand) for p in _solid(line, off)]
|
||||
if section.lanes >= 4:
|
||||
# Zwischen den gleichgerichteten Spuren, je Richtung einer.
|
||||
out += [(p, "weiss_gestrichelt") for off in (-LANE, LANE) for p in _dashed(line, off)]
|
||||
return out
|
||||
|
||||
|
||||
def _offset(line: LineString, distance: float) -> LineString:
|
||||
def _offset(line: LineString, distance: float) -> list[LineString]:
|
||||
"""Der versetzte Linienzug, als Liste normalisiert (Befund 4 der Fixwelle).
|
||||
|
||||
offset_curve() liefert bei einem engen Zickzack keine einzelne LineString, sondern eine
|
||||
MultiLineString (nachgestellt: eine S-Kurve mit 1 m Amplitude, LANE=3,5 m Versatz spaltet sich
|
||||
in zwei Teilstücke) - substring() in _dashed() akzeptiert aber nur LineString und wirft sonst
|
||||
GeometryTypeError. An einer sehr spitzen Kehre kann das Ergebnis auch ganz leer sein; eine
|
||||
leere LineString ungeprüft gepuffert (_solid()) ergäbe ein Nullflächen-Polygon statt gar
|
||||
nichts. Heute tritt keiner der beiden Fälle am Mosaik auf, aber eine geänderte Korrektur, ein
|
||||
anderer Ausschnitt oder eine eingehängte Kante mit engem Knick reicht dafür.
|
||||
"""
|
||||
if distance == 0.0:
|
||||
return line
|
||||
return [line]
|
||||
# offset_curve versetzt nach links; negative Werte nach rechts. Bei geraden Straßen ist das
|
||||
# exakt, bei Knicken folgt die Kurve dem Knick - genau das soll eine Markierung auch.
|
||||
return line.offset_curve(distance)
|
||||
versetzt = line.offset_curve(distance)
|
||||
if versetzt.is_empty:
|
||||
return []
|
||||
return list(versetzt.geoms) if versetzt.geom_type == "MultiLineString" else [versetzt]
|
||||
|
||||
|
||||
def _solid(line: LineString, distance: float):
|
||||
return _offset(line, distance).buffer(WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5)
|
||||
def _solid(line: LineString, distance: float) -> list[Polygon]:
|
||||
return [teil.buffer(WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5)
|
||||
for teil in _offset(line, distance)]
|
||||
|
||||
|
||||
def _dashed(line: LineString, distance: float) -> list:
|
||||
"""Striche im Takt DASH/GAP, beginnend am Anfang der Kante.
|
||||
"""Striche im Takt DASH/GAP, beginnend am Anfang jedes Teilstücks.
|
||||
|
||||
Die Phase läuft bewusst nicht über Kreuzungen hinweg weiter: Jede Kante fängt neu an. Alles
|
||||
andere hieße, eine Ordnung zu behaupten, die die Karte gar nicht hergibt.
|
||||
andere hieße, eine Ordnung zu behaupten, die die Karte gar nicht hergibt. Ein Zickzack-Versatz
|
||||
aus mehreren Teilstücken (_offset()) fängt entsprechend in jedem Teilstück neu an - eine Naht,
|
||||
die am Mosaik nicht vorkommt und die auch die Kreuzungsgrenze schon in Kauf nimmt.
|
||||
"""
|
||||
versetzt = _offset(line, distance)
|
||||
out, at = [], 0.0
|
||||
while at < versetzt.length:
|
||||
end = min(at + DASH, versetzt.length)
|
||||
if end - at > 0.5:
|
||||
out.append(substring(versetzt, at, end).buffer(
|
||||
WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5))
|
||||
at = end + GAP
|
||||
out = []
|
||||
for versetzt in _offset(line, distance):
|
||||
at = 0.0
|
||||
while at < versetzt.length:
|
||||
end = min(at + DASH, versetzt.length)
|
||||
if end - at > 0.5:
|
||||
out.append(substring(versetzt, at, end).buffer(
|
||||
WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5))
|
||||
at = end + GAP
|
||||
return out
|
||||
|
||||
|
||||
@@ -116,7 +133,18 @@ def junction_marks(graph: Graph, node: int) -> list[tuple[Polygon, str]]:
|
||||
richtungen = ([edge.points, list(reversed(edge.points))] if edge.a == edge.b
|
||||
else [edge.points if edge.a == node else list(reversed(edge.points))])
|
||||
for points in richtungen:
|
||||
along = _unit(origin, points[1] if len(points) > 1 else points[0])
|
||||
line = LineString(points)
|
||||
länge = line.length
|
||||
# Die Richtung kommt aus der tatsächlichen Kante bei Streckenlänge "reach", nicht aus
|
||||
# dem zweiten Stützpunkt (Befund 3 der Fixwelle): Ein Knick konnte innerhalb der ersten
|
||||
# reach Meter liegen - reach ist der Kreuzungsradius plus Luft, oft mehrere Meter -,
|
||||
# während die alte Richtung (Anfang -> zweiter Stützpunkt) bis reach + STOP_SETBACK +
|
||||
# STOP_DEPTH (bis 16 m) unverändert weiterlief. Am echten Netz lagen dadurch 452,65 m²
|
||||
# Markierungsfläche (1,03 % der Gesamtfläche) neben statt auf der Fahrbahn, darunter
|
||||
# ganze Haltelinien. min(reach, länge) schützt kurze Arme, die schon vorher an der
|
||||
# Prüfung unten scheitern und dann gar nicht gezeichnet werden.
|
||||
punkt = line.interpolate(min(reach, länge))
|
||||
along = _unit(origin, (punkt.x, punkt.y))
|
||||
across = (-along[1], along[0])
|
||||
halb = SECTION[edge.klass].carriageway / 2
|
||||
# reach kommt vom breitesten Arm der ganzen Kreuzung, gilt hier aber für diesen einen
|
||||
@@ -128,7 +156,6 @@ def junction_marks(graph: Graph, node: int) -> list[tuple[Polygon, str]]:
|
||||
# das Ende der Kante hinaus und läge neben der Straße statt darauf. Zebrastreifen und
|
||||
# Haltelinie brauchen unterschiedlich viel Platz, deshalb getrennt geprüft statt
|
||||
# gemeinsam verworfen.
|
||||
länge = LineString(points).length
|
||||
if länge >= reach + CROSSWALK_DEPTH:
|
||||
# Balken längs der Fahrtrichtung, quer über die Fahrbahn verteilt.
|
||||
offset = -halb + BAR / 2
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from shapely.geometry import LineString
|
||||
|
||||
from leonida.roads import Edge, Graph, Node
|
||||
from leonida.marking import CROSSWALK_DEPTH, junction_marks, markings
|
||||
from leonida.section import SECTION, setback
|
||||
@@ -97,6 +99,42 @@ def test_schleife_bekommt_haltelinie_fuer_beide_richtungen():
|
||||
assert zahl["haltelinie"] == 4, "zwei gerade Arme plus zwei Richtungen der Schleife"
|
||||
|
||||
|
||||
def test_markierung_folgt_dem_geknickten_arm_nicht_der_ersten_richtung():
|
||||
"""Befund 3 der Fixwelle: "along" kam aus der Richtung origin -> points[1], also allein aus
|
||||
dem ersten Segment der Zufahrt. Knickt die Kante innerhalb von reach + STOP_SETBACK +
|
||||
STOP_DEPTH ab (bis 16 m), blieben Zebrastreifen und Haltelinie auf der Verlängerung des
|
||||
ersten Segments stehen, statt der tatsächlichen Kante zu folgen. Am echten Netz lagen dadurch
|
||||
452,65 m² Markierungsfläche (1,03 %) neben der Fahrbahn, darunter ganze Haltelinien.
|
||||
|
||||
Nachgebaut mit einer Zufahrt, die 1 m nach dem Knoten um 90° abknickt: reach (5,5 m bei
|
||||
Wohnstraße) und erst recht die Haltelinie bei 9,7-10,3 m liegen weit hinter dem Knick. Der
|
||||
zweite Arm zeigt nach Westen, weit weg von jeder möglichen Richtung des ersten - er dient nur
|
||||
dazu, die Mindestzahl von zwei Zufahrten mit Bordstein zu erfüllen (`echte`).
|
||||
"""
|
||||
arm = [(0.0, 0.0), (0.0, -1.0), (20.0, -1.0)]
|
||||
graph = Graph(
|
||||
nodes=[Node((0.0, 0.0)), Node((-80.0, 0.0)), Node((20.0, -1.0))],
|
||||
edges=[Edge(0, 1, [(0.0, 0.0), (-80.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"),
|
||||
Edge(0, 2, arm, SECTION["wohnstrasse"].corridor, "wohnstrasse")],
|
||||
)
|
||||
halb = SECTION["wohnstrasse"].carriageway / 2
|
||||
fahrbahn = LineString(arm).buffer(halb, cap_style="flat")
|
||||
# Der Westarm liegt bei x <= -80 * 0,15 (Kreuzungsradius) .. deutlich unter -4; alles mit
|
||||
# centroid.x > -4 gehört zur geknickten Zufahrt, gleich in welche Richtung sie zeigt.
|
||||
ziel = [(p, a) for p, a in junction_marks(graph, 0) if p.centroid.x > -4]
|
||||
haltelinie = next(p for p, a in ziel if a == "haltelinie")
|
||||
anteil_halt = haltelinie.intersection(fahrbahn).area / haltelinie.area
|
||||
assert anteil_halt > 0.9, (
|
||||
f"Haltelinie liegt nur zu {anteil_halt:.0%} auf der (geknickten) Fahrbahn - sie folgt "
|
||||
"noch der Richtung des ersten Segments statt der tatsächlichen Kante"
|
||||
)
|
||||
zebra = [p for p, a in ziel if a == "zebra"]
|
||||
anteile = [p.intersection(fahrbahn).area / p.area for p in zebra]
|
||||
mittel = sum(anteile) / len(anteile)
|
||||
assert mittel > 0.7, f"Zebrastreifen im Mittel nur zu {mittel:.0%} auf der Fahrbahn"
|
||||
assert sum(1 for a in anteile if a > 0.9) >= len(anteile) - 1, "fast jeder Balken muss klar auf der Fahrbahn liegen"
|
||||
|
||||
|
||||
def test_kurzer_arm_an_breiter_kreuzung_bleibt_ohne_markierung():
|
||||
# reach = setback() kommt vom breitesten anliegenden Arm (hier Boulevard, halbe Fahrbahn 9 m)
|
||||
# und gilt für alle Zufahrten gleich - ein kurzer Arm reicht dann nicht mehr bis dorthin. Am
|
||||
|
||||
@@ -77,6 +77,38 @@ def test_linien_sind_zehn_zentimeter_breit():
|
||||
assert round(x1 - x0, 3) == DASH
|
||||
|
||||
|
||||
def test_dashed_stuerzt_bei_engem_zickzack_nicht_ab():
|
||||
"""Befund 4 der Fixwelle: offset_curve() liefert bei einem engen Zickzack eine
|
||||
MultiLineString statt einer LineString - substring() in _dashed() akzeptiert aber nur
|
||||
LineString und wirft sonst GeometryTypeError. Nachgestellt mit einer S-Kurve (1 m Amplitude):
|
||||
Bei LANE=3,5 m Versatz (Spurtrenner, nur ab Boulevard) spaltet offset_curve(-3,5) sie in zwei
|
||||
Teilstücke. Vor der Korrektur bricht das hier mit GeometryTypeError ab; das ist die
|
||||
Bestätigung, dass der Test ohne sie rot ist.
|
||||
"""
|
||||
zickzack = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (2.0, 1.0), (2.0, 0.0), (3.0, 0.0)]
|
||||
graph = Graph(nodes=[Node((0.0, 0.0)), Node((3.0, 0.0))],
|
||||
edges=[Edge(0, 1, zickzack, SECTION["boulevard"].corridor, "boulevard")])
|
||||
ergebnis = longitudinal(graph) # darf nicht mit GeometryTypeError abbrechen
|
||||
arten = {art for _, art in ergebnis}
|
||||
assert "weiss_gestrichelt" in arten, "der Spurtrenner entsteht trotz Zickzack (in Teilstücken)"
|
||||
|
||||
|
||||
def test_solid_erzeugt_kein_nullflaechen_polygon_bei_leerer_offset_curve():
|
||||
"""Befund 4 der Fixwelle, zweiter (stiller) Ast: An einer sehr spitzen Kehre liefert
|
||||
offset_curve() eine leere LineString. _solid() puffert sie ungeprüft zu einem
|
||||
Nullflächen-Polygon statt gar nichts zurückzugeben - kein Absturz, aber eine entartete
|
||||
Geometrie in der Ausgabe. Nachgestellt mit einer Kehre, die bei WIDTH (0,1 m, gelb_doppelt)
|
||||
und bei rand (7 m beim Boulevard, weiss_rand) je auf einer Seite ins Leere läuft.
|
||||
"""
|
||||
kehre = [(0.0, 0.0), (1.0, 0.0), (0.0, 0.01)]
|
||||
graph = Graph(nodes=[Node((0.0, 0.0)), Node((0.0, 0.01))],
|
||||
edges=[Edge(0, 1, kehre, SECTION["boulevard"].corridor, "boulevard")])
|
||||
flaechen = [p.area for p, art in longitudinal(graph) if art in ("gelb_doppelt", "weiss_rand")]
|
||||
assert all(a > 0.01 for a in flaechen), (
|
||||
f"eine leere offset_curve() darf kein Nullflächen-Polygon in der Ausgabe hinterlassen: {flaechen}"
|
||||
)
|
||||
|
||||
|
||||
def test_markierung_endet_am_kreuzungsrand():
|
||||
graph = Graph(nodes=[Node((0.0, 0.0)), Node((120.0, 0.0)), Node((120.0, 60.0)), Node((180.0, 0.0))],
|
||||
edges=[Edge(0, 1, [(0.0, 0.0), (120.0, 0.0)], 12.0, "wohnstrasse"),
|
||||
|
||||
Reference in new issue
Block a user