diff --git a/tools/extract/leonida/marking.py b/tools/extract/leonida/marking.py index 84d8d26..a978055 100644 --- a/tools/extract/leonida/marking.py +++ b/tools/extract/leonida/marking.py @@ -15,7 +15,7 @@ from shapely.geometry import LineString, Polygon from shapely.ops import substring from .roads import Graph -from .section import LANE, SECTION, trimmed_line +from .section import LANE, SECTION, setback, trimmed_line WIDTH = 0.10 # 4 in DASH = 3.0 @@ -76,3 +76,77 @@ def _dashed(line: LineString, distance: float) -> list: WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5)) at = end + GAP return out + + +CROSSWALK_DEPTH = 3.0 # Tiefe des Zebrastreifens in Fahrtrichtung, 10 ft +BAR = 0.6 # Balkenbreite und Lücke, 2 ft - das Miami-Balkenmuster +STOP_DEPTH = 0.6 +STOP_SETBACK = 4.2 # Kreuzungsrand + Zebrastreifen (3,0) + Sicherheitsabstand (1,2) + + +def markings(graph: Graph) -> list[tuple[Polygon, str]]: + out = longitudinal(graph) + for node in range(len(graph.nodes)): + if graph.degree(node) >= 3: + out += junction_marks(graph, node) + return out + + +def junction_marks(graph: Graph, node: int) -> list[tuple[Polygon, str]]: + """Zebrastreifen und Haltelinie je Zufahrt. + + Ein Zebrastreifen entsteht nur, wenn die Kreuzung mindestens zwei Arme ab Wohnstraße hat - + zwischen zwei Gassen quert niemand. Arme, die selbst Gassen sind, bleiben in jedem Fall + blank. + """ + incident = graph.incident(node) + echte = [k for k in incident if SECTION[graph.edges[k].klass].kerb] + if len(echte) < 2: + return [] + reach = setback(graph, node) + origin = graph.nodes[node].point + out = [] + for k in echte: + edge = graph.edges[k] + # Eine Schleife hängt mit beiden Enden am selben Knoten und stellt deshalb zwei getrennte + # Zufahrten dar (wie bei junction_arms() in section.py - Fixrunde zu Task 9, am echten + # Netz betrifft das 28 von 690 Kreuzungen mit einer bordsteintragenden Schleife). incident() + # liefert sie nur einmal, und edge.a == node ist bei ihr immer wahr - ohne die zweite + # Richtung bekäme die Schleife auf einer ihrer beiden Seiten weder Haltelinie noch Zebra. + 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]) + across = (-along[1], along[0]) + halb = SECTION[edge.klass].carriageway / 2 + # Balken längs der Fahrtrichtung, quer über die Fahrbahn verteilt. + offset = -halb + BAR / 2 + while offset <= halb - BAR / 2 + 1e-9: + mitte = (origin[0] + across[0] * offset, origin[1] + across[1] * offset) + out.append((_rect(mitte, along, across, reach, CROSSWALK_DEPTH, BAR), "zebra")) + offset += 2 * BAR + # Haltelinie quer, nur über die rechte Hälfte aus Sicht der Zufahrt. "across" ist schon + # als links von "along" definiert (dieselbe Konvention wie offset_curve() in _offset(): + # positiv = links). Die Zufahrt fährt zum Knoten hin, also gegen "along" - und die Umkehr + # der Fahrtrichtung vertauscht links und rechts. Rechts der Einfahrt ist deshalb +across, + # ohne weiteres Vorzeichen: eine Probe mit offset_curve() auf der Einfahrtlinie bestätigt + # das (Fixrunde zu Task 9 - der Auftrag hatte hier -across und lag damit auf der Gegenspur). + mitte = (origin[0] + across[0] * halb / 2, origin[1] + across[1] * halb / 2) + out.append((_rect(mitte, along, across, reach + STOP_SETBACK, STOP_DEPTH, halb), "haltelinie")) + return out + + +def _unit(a: tuple[float, float], b: tuple[float, float]) -> tuple[float, float]: + dx, dy = b[0] - a[0], b[1] - a[1] + length = (dx * dx + dy * dy) ** 0.5 or 1.0 + return (dx / length, dy / length) + + +def _rect(mitte, along, across, start: float, length: float, width: float) -> Polygon: + """Rechteck im Fahrbahnsystem: start..start+length in Fahrtrichtung, width quer um mitte.""" + punkte = [] + for dl, dq in ((start, -width / 2), (start + length, -width / 2), + (start + length, width / 2), (start, width / 2)): + punkte.append((mitte[0] + along[0] * dl + across[0] * dq, + mitte[1] + along[1] * dl + across[1] * dq)) + return Polygon(punkte) diff --git a/tools/extract/tests/test_marking_junction.py b/tools/extract/tests/test_marking_junction.py new file mode 100644 index 0000000..824050c --- /dev/null +++ b/tools/extract/tests/test_marking_junction.py @@ -0,0 +1,90 @@ +"""Haltelinien und Zebrastreifen.""" +from __future__ import annotations + +from collections import Counter + +from leonida.roads import Edge, Graph, Node +from leonida.marking import CROSSWALK_DEPTH, junction_marks, markings +from leonida.section import SECTION, setback + + +def kreuzung(klassen=("hauptstrasse",) * 4, arm: float = 80.0) -> Graph: + punkte = [(arm, 0.0), (-arm, 0.0), (0.0, arm), (0.0, -arm)] + return Graph(nodes=[Node((0.0, 0.0))] + [Node(p) for p in punkte], + edges=[Edge(0, i + 1, [(0.0, 0.0), p], SECTION[k].corridor, k) + for i, (p, k) in enumerate(zip(punkte, klassen))]) + + +def test_jeder_arm_bekommt_zebra_und_haltelinie(): + zahl = Counter(art for _, art in junction_marks(kreuzung(), 0)) + assert zahl["haltelinie"] == 4, "eine je Zufahrt" + assert zahl["zebra"] > 4 * 5, "je Arm mehrere Balken" + + +def test_zebra_liegt_am_kreuzungsrand(): + rand = setback(kreuzung(), 0) + zebra = [p for p, art in junction_marks(kreuzung(), 0) if art == "zebra"] + # Der Arm nach Osten: Balken zwischen Kreuzungsrand und Kreuzungsrand plus Tiefe. Die + # Querbedingung muss enger sein als die halbe Fahrbahnbreite (6 m): Bei |y| < 20 träfe sie + # auch Nord- und Südarm-Balken nahe der Ecke (deren eigene Querachse dort ohnehin liegt), die + # aber bei x um 8-11 m liegen wie der Ostarm selbst - falsches Grün durch Verwechslung, keine + # echte Prüfung (Fixrunde zu Task 9). + halb = SECTION["hauptstrasse"].carriageway / 2 + ost = [p for p in zebra if p.centroid.x > 0 and abs(p.centroid.y) < halb] + assert ost, "der Ostarm hat Balken" + for poly in ost: + assert rand - 0.1 <= poly.bounds[0] <= rand + CROSSWALK_DEPTH + + +def test_haltelinie_liegt_hinter_dem_zebrastreifen(): + rand = setback(kreuzung(), 0) + halt = [p for p, art in junction_marks(kreuzung(), 0) if art == "haltelinie"] + ost = next(p for p in halt if p.centroid.x > 0 and abs(p.centroid.y) < 20) + assert ost.bounds[0] > rand + CROSSWALK_DEPTH, "erst Zebrastreifen, dann Haltelinie" + + +def test_haltelinie_deckt_nur_die_rechte_haelfte(): + halt = [p for p, art in junction_marks(kreuzung(), 0) if art == "haltelinie"] + ost = next(p for p in halt if p.centroid.x > 0 and abs(p.centroid.y) < 20) + # Zufahrt von Osten nach Westen, also Fahrtrichtung (-1, 0): Rechts davon liegt y > 0 - per + # offset_curve()-Konvention nachgerechnet (negativ = rechts der Linienrichtung), nicht nur + # behauptet. Der Auftrag ging von y < 0 aus; das war die Gegenspur (Fixrunde zu Task 9). + assert ost.centroid.y > 0 + assert round(ost.bounds[3] - ost.bounds[1], 1) == round(SECTION["hauptstrasse"].carriageway / 2, 1) + + +def test_arm_der_eine_gasse_ist_bekommt_keinen_zebrastreifen(): + graph = kreuzung(("hauptstrasse", "hauptstrasse", "hauptstrasse", "gasse")) + marks = junction_marks(graph, 0) + # Wie bei test_zebra_liegt_am_kreuzungsrand: |x| < 20 wäre zu locker und träfe auch Ost- und + # Westarm-Balken nahe der Ecke, obwohl deren Querachse (|x| bis 9,5 m für Zebra, 12,5 m für + # die Haltelinie) klar außerhalb der Südarm-Fahrbahnhälfte liegt (Fixrunde zu Task 9). + halb = SECTION["hauptstrasse"].carriageway / 2 + süd = [art for p, art in marks if p.centroid.y < -1 and abs(p.centroid.x) < halb] + assert süd == [], "der Gassenarm bleibt blank" + + +def test_kreuzung_aus_lauter_gassen_bekommt_nichts(): + assert junction_marks(kreuzung(("gasse",) * 4), 0) == [] + + +def test_markings_sammelt_laengs_und_kreuzung(): + arten = {art for _, art in markings(kreuzung())} + assert "gelb_doppelt" in arten and "zebra" in arten and "haltelinie" in arten + + +def test_schleife_bekommt_haltelinie_fuer_beide_richtungen(): + # Wie test_schleife_bekommt_zwei_stummel_kein_loch_im_belag in test_junction.py: Eine + # Schleife hängt mit beiden Enden am selben Knoten. graph.incident() liefert sie nur einmal, + # aber sie stellt zwei getrennte Abgangsrichtungen dar (Verkehr kann von beiden Seiten in die + # Kreuzung einfahren) - am echten Netz betrifft das 28 von 690 Kreuzungen mit einer + # bordsteintragenden Schleife. Ohne die zweite Richtung fehlten Haltelinie und Zebrastreifen + # auf der einen Seite der Schleife komplett. + graph = Graph( + nodes=[Node((0.0, 0.0)), Node((-100.0, 0.0)), Node((0.0, -100.0))], + edges=[Edge(0, 0, [(0.0, 0.0), (0.0, 20.0), (20.0, 20.0), (20.0, 0.0), (0.0, 0.0)], + SECTION["hauptstrasse"].corridor, "hauptstrasse"), + Edge(0, 1, [(0.0, 0.0), (-100.0, 0.0)], SECTION["hauptstrasse"].corridor, "hauptstrasse"), + Edge(0, 2, [(0.0, 0.0), (0.0, -100.0)], SECTION["hauptstrasse"].corridor, "hauptstrasse")]) + zahl = Counter(art for _, art in junction_marks(graph, 0)) + assert zahl["haltelinie"] == 4, "zwei gerade Arme plus zwei Richtungen der Schleife"