From 83140f89e1400e2dc1040822064b079990358444 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Fri, 18 Sep 2026 06:35:36 +0200 Subject: [PATCH] =?UTF-8?q?fix(extract):=20Markierungen=20folgen=20der=20K?= =?UTF-8?q?ante=20und=20st=C3=BCrzen=20nicht=20am=20Zickzack=20ab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tools/extract/leonida/marking.py | 65 ++++++++++++++------ tools/extract/tests/test_marking_junction.py | 38 ++++++++++++ tools/extract/tests/test_marking_long.py | 32 ++++++++++ 3 files changed, 116 insertions(+), 19 deletions(-) diff --git a/tools/extract/leonida/marking.py b/tools/extract/leonida/marking.py index 15783a3..1cbe3d3 100644 --- a/tools/extract/leonida/marking.py +++ b/tools/extract/leonida/marking.py @@ -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 diff --git a/tools/extract/tests/test_marking_junction.py b/tools/extract/tests/test_marking_junction.py index b6a38c5..ff19a4c 100644 --- a/tools/extract/tests/test_marking_junction.py +++ b/tools/extract/tests/test_marking_junction.py @@ -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 diff --git a/tools/extract/tests/test_marking_long.py b/tools/extract/tests/test_marking_long.py index 9662dae..afb1118 100644 --- a/tools/extract/tests/test_marking_long.py +++ b/tools/extract/tests/test_marking_long.py @@ -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"),