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:
knso committed 2026-09-18 06:35:36 +02:00
1 parent ceb23bf912
commit 83140f89e1
3 files changed
+116 -19

No files matched your search

+46 -19
View File
@@ -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 # 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 # WIDTH = 10 cm als Lücke zwischen den gemalten Linien übrig bleiben - das ist der im
# Entwurf verlangte Abstand, nicht der Achsabstand selbst. # 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 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: if section.lanes >= 4:
# Zwischen den gleichgerichteten Spuren, je Richtung einer. # Zwischen den gleichgerichteten Spuren, je Richtung einer.
out += [(p, "weiss_gestrichelt") for off in (-LANE, LANE) for p in _dashed(line, off)] out += [(p, "weiss_gestrichelt") for off in (-LANE, LANE) for p in _dashed(line, off)]
return out 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: if distance == 0.0:
return line return [line]
# offset_curve versetzt nach links; negative Werte nach rechts. Bei geraden Straßen ist das # 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. # 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): def _solid(line: LineString, distance: float) -> list[Polygon]:
return _offset(line, distance).buffer(WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5) 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: 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 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 = []
out, at = [], 0.0 for versetzt in _offset(line, distance):
while at < versetzt.length: at = 0.0
end = min(at + DASH, versetzt.length) while at < versetzt.length:
if end - at > 0.5: end = min(at + DASH, versetzt.length)
out.append(substring(versetzt, at, end).buffer( if end - at > 0.5:
WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5)) out.append(substring(versetzt, at, end).buffer(
at = end + GAP WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5))
at = end + GAP
return out 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 richtungen = ([edge.points, list(reversed(edge.points))] if edge.a == edge.b
else [edge.points if edge.a == node else list(reversed(edge.points))]) else [edge.points if edge.a == node else list(reversed(edge.points))])
for points in richtungen: 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]) across = (-along[1], along[0])
halb = SECTION[edge.klass].carriageway / 2 halb = SECTION[edge.klass].carriageway / 2
# reach kommt vom breitesten Arm der ganzen Kreuzung, gilt hier aber für diesen einen # 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 # 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 # Haltelinie brauchen unterschiedlich viel Platz, deshalb getrennt geprüft statt
# gemeinsam verworfen. # gemeinsam verworfen.
länge = LineString(points).length
if länge >= reach + CROSSWALK_DEPTH: if länge >= reach + CROSSWALK_DEPTH:
# Balken längs der Fahrtrichtung, quer über die Fahrbahn verteilt. # Balken längs der Fahrtrichtung, quer über die Fahrbahn verteilt.
offset = -halb + BAR / 2 offset = -halb + BAR / 2
@@ -3,6 +3,8 @@ from __future__ import annotations
from collections import Counter from collections import Counter
from shapely.geometry import LineString
from leonida.roads import Edge, Graph, Node from leonida.roads import Edge, Graph, Node
from leonida.marking import CROSSWALK_DEPTH, junction_marks, markings from leonida.marking import CROSSWALK_DEPTH, junction_marks, markings
from leonida.section import SECTION, setback 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" 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(): def test_kurzer_arm_an_breiter_kreuzung_bleibt_ohne_markierung():
# reach = setback() kommt vom breitesten anliegenden Arm (hier Boulevard, halbe Fahrbahn 9 m) # 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 # und gilt für alle Zufahrten gleich - ein kurzer Arm reicht dann nicht mehr bis dorthin. Am
+32
View File
@@ -77,6 +77,38 @@ def test_linien_sind_zehn_zentimeter_breit():
assert round(x1 - x0, 3) == DASH 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(): 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))], 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"), edges=[Edge(0, 1, [(0.0, 0.0), (120.0, 0.0)], 12.0, "wohnstrasse"),