feat(extract): Wiederaufbau von Straßen, Wegen und Flächen unter Beschriftungen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-13 18:37:05 +02:00
1 parent c9e0615a88
commit 368ea90b3a
2 files changed
+559

No files matched your search

+395
View File
@@ -0,0 +1,395 @@
"""Wiederaufbau des Klassenbilds unter entfernten Kartenbeschriftungen.
Spec: docs/superpowers/specs/2026-09-13-beschriftungen-design.md, Abschnitt „Wiederaufbau“.
Alle Funktionen arbeiten auf dem Klassenbild aus classify() (1 px = 1 m) und verändern es an Ort
und Stelle. Pixelkoordinaten sind (Zeile, Spalte), Zeilen wachsen nach Süden. Weltkoordinaten
kennt dieses Modul nicht; die Umrechnung macht annotations.py.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
import numpy as np
from scipy import ndimage as ndi
from skimage.draw import disk
from skimage.morphology import skeletonize
from .classify import CLASSES
from .vectors import _skeleton_graph
ROAD_ORDER = ("weg", "spekulativ", "strasse") # Malreihenfolge: später gemalt gewinnt
AREA_CLASSES = ("wasser", "land", "gruen", "sand", "stadt")
WINDOW_MARGIN = 60 # m um das Hüllrechteck der Maske
THIN_DEPTH = 2.0 # m: bis zu diesem Abstand vom Maskenrand gilt eine Maske als dünn
TRUST = 10.0 # m: Mindestabstand vertrauter Mittellinienpunkte zur Maske
DIRECTION_LENGTH = 15.0 # m Mittellinie für die Richtung eines Straßenendes
WIDTH_LENGTH = 5.0 # m Mittellinie für die Halbbreite — weiter hinten liegt oft schon die Kreuzung
MIN_TRUSTED = 5.0 # m: kürzere vertraute Stücke ergeben kein Straßenende
REACH = 20.0 # m: so weit darf die Maske vor einem Straßenende liegen
CROSS_THROUGH = 60.0 # m: größter Abstand zwischen zwei Anschlüssen, über die ein Strahl weiterläuft
EDGE_MARGIN = 8 # m: so nah am Rand des Ausschnitts ist das Skelett unzuverlässig
MAX_ANGLE = 20.0 # Grad zwischen Richtung eines Endes und der Verbindungslinie
WIDTH_RATIO = 1.6 # Halbbreiten eines Paares höchstens um diesen Faktor verschieden
CORRIDOR_SHARE = 0.9 # Anteil der Kurvenpunkte, die auf Maske oder sichtbarer Straße liegen müssen
TARGET_MIN_INSIDE = 3 # Kurvenpunkte in der Maske, ab denen ein Ziel auf der anderen Seite liegt
SOURCE_MIN_AREA = 50 # m²: kleinere Flächenstücke sind Kantenglättung und taugen nicht als Füllquelle
@dataclass
class RebuildStats:
connected: int = 0 # Paare von Straßenenden und Enden mit Ziel auf der anderen Seite
extended: int = 0 # geradeaus weitergeführte Enden, die anschließen
dead_ends: int = 0 # Enden ohne jeden Anschluss; sie werden nicht gemalt
closed: int = 0 # geschlossene Gebäude
added: int = 0 # ergänzte Gebäude
note: str = ""
@dataclass
class RebuildResult:
stats: RebuildStats
buildings: list[list[tuple[float, float]]] = field(default_factory=list) # Ecken (Zeile, Spalte)
@dataclass(frozen=True)
class RoadEnd:
row: float
col: float
dr: float # Einheitsvektor in Richtung Maske
dc: float
half: float # Halbbreite in m
def fill_nearest(labels: np.ndarray, target: np.ndarray, blocked: np.ndarray) -> None:
"""Jedes Pixel in target übernimmt die Klasse des nächsten Pixels außerhalb von blocked."""
if not target.any() or blocked.all():
return
index = ndi.distance_transform_edt(blocked, return_distances=False, return_indices=True)
rows, cols = np.nonzero(target)
labels[rows, cols] = labels[index[0][rows, cols], index[1][rows, cols]]
def is_thin(mask: np.ndarray) -> bool:
"""Dünn: kein Maskenpixel liegt weiter als THIN_DEPTH vom Maskenrand entfernt."""
return float(ndi.distance_transform_edt(np.pad(mask, 1)).max()) <= THIN_DEPTH
def rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = None) -> RebuildResult:
"""Baut das Klassenbild unter einer zusammenhängenden Maske wieder auf.
blocked sind alle Pixel, die nicht als Quelle taugen — alle Beschriftungsmasken; ohne
Angabe nur diese Maske. Reihenfolge wie in der Spec: Straßen und Wege, dann Flächen.
"""
blocked = mask if blocked is None else blocked
stats = RebuildStats()
win = _window(mask, WINDOW_MARGIN)
L, M, B = labels[win], mask[win], blocked[win]
edges = (win[0].start == 0, win[0].stop == labels.shape[0], win[1].start == 0, win[1].stop == labels.shape[1])
painted = np.zeros(M.shape, dtype=bool)
for road, points, halves in _plan_roads(L, M, B, edges, stats):
_stamp(L, M, painted, road, points, halves)
_fill_areas(L, M & ~painted, B)
return RebuildResult(stats)
# --- Straßen und Wege -----------------------------------------------------------------------
def _plan_roads(L, M, B, edges, stats):
"""Plant alle Striche, höherrangige Klassen zuerst, damit niedrigere an ihnen anschließen.
Gemalt wird danach in ROAD_ORDER, damit an Kreuzungen die Straße gewinnt.
"""
dist_mask = ndi.distance_transform_edt(~M)
any_road = np.isin(L, [CLASSES[name] for name in ROAD_ORDER]) & ~B
centre = np.zeros(M.shape, dtype=bool)
strokes = {name: [] for name in ROAD_ORDER}
for name in reversed(ROAD_ORDER):
road = CLASSES[name]
visible = (L == road) & ~B
if not visible.any():
continue
# Die Maske zählt beim Skelettieren als Straße: sonst schrumpft die Mittellinie einer
# abgeschnittenen Straße um ihre Halbbreite und erreicht die 10-m-Zone nie.
skeleton = skeletonize(visible | M) & ~M
graph = _skeleton_graph(skeleton)
width = ndi.distance_transform_edt(visible)
ends = [e for e in _road_ends(graph, dist_mask, width) if not _near_edge(e, M.shape, edges)]
pairs, single = _pair_ends(ends, M | any_road)
for a, b in pairs:
points = _hermite(a, b)
strokes[name].append((road, points, np.linspace(a.half, b.half, len(points))))
_mark_centre(centre, points)
stats.connected += 1
zone = np.argwhere(skeleton & visible & (dist_mask < TRUST))
for end, length, joins in _cast_rays(L, B, dist_mask, road, single, centre, edges):
if joins:
count = max(2, int(math.ceil(length * 2)) + 1)
steps = np.linspace(0.0, length, count)
points = list(zip(end.row + end.dr * steps, end.col + end.dc * steps))
stats.extended += 1
else:
points = _to_target(end, zone, M, M | any_road)
if points is None:
stats.dead_ends += 1 # Sackgassen werden nicht gemalt
continue
stats.connected += 1
strokes[name].append((road, points, [end.half] * len(points)))
_mark_centre(centre, points)
return [stroke for name in ROAD_ORDER for stroke in strokes[name]]
def _to_target(end: RoadEnd, zone, M, passable):
"""Kurve zur sichtbaren Straße derselben Klasse auf der anderen Seite der Maske, sonst None."""
best = None
for r, c in zone:
vr, vc = r - end.row, c - end.col
if math.hypot(vr, vc) == 0:
continue
angle = _angle((end.dr, end.dc), (vr, vc))
if angle > MAX_ANGLE or (best is not None and angle >= best[0]):
continue
norm = math.hypot(vr, vc)
target = RoadEnd(float(r), float(c), -vr / norm, -vc / norm, end.half)
points = _hermite(end, target)
inside = sum(1 for pr, pc in points if M[int(round(pr)), int(round(pc))])
if inside >= TARGET_MIN_INSIDE and _corridor(passable, points):
best = (angle, points)
return None if best is None else best[1]
def _road_ends(graph, dist_mask, width) -> list[RoadEnd]:
ends = []
for node in sorted(graph.nodes):
if dist_mask[node] < TRUST:
continue
inward = [n for n in graph.neighbors(node) if dist_mask[n] < TRUST]
if not inward:
continue
trace = _trace(graph, node, inward, dist_mask)
if _length(trace) < MIN_TRUSTED:
continue
dr, dc = node[0] - trace[-1][0], node[1] - trace[-1][1]
norm = math.hypot(dr, dc)
near = [p for k, p in enumerate(trace) if _length(trace[:k + 1]) <= WIDTH_LENGTH]
half = float(np.median([width[p] for p in near]))
end = RoadEnd(float(node[0]), float(node[1]), dr / norm, dc / norm, half)
if not _reaches(dist_mask, end):
continue
if not any(math.hypot(e.row - end.row, e.col - end.col) <= 3 and e.dr * end.dr + e.dc * end.dc > 0.9
for e in ends):
ends.append(end)
return ends
def _trace(graph, start, inward, dist_mask):
"""Mittellinie ab start von der Maske weg, an Verzweigungen möglichst geradeaus."""
path = [start]
seen = {start, *inward}
heading = (start[0] - sum(n[0] for n in inward) / len(inward),
start[1] - sum(n[1] for n in inward) / len(inward))
while _length(path) < DIRECTION_LENGTH:
current = path[-1]
options = [n for n in graph.neighbors(current) if n not in seen and dist_mask[n] >= TRUST]
if not options:
break
if len(path) >= 3:
ref = path[max(0, len(path) - 6)]
heading = (current[0] - ref[0], current[1] - ref[1])
nxt = max(options, key=lambda n: _cos((n[0] - current[0], n[1] - current[1]), heading))
path.append(nxt)
seen.add(nxt)
return path
def _reaches(dist_mask, end: RoadEnd) -> bool:
"""Liegt innerhalb von REACH vor dem Ende Maske höchstens eine Halbbreite neben dem Strahl?"""
h, w = dist_mask.shape
for s in np.arange(0.5, REACH + 0.25, 0.5):
r, c = int(round(end.row + end.dr * s)), int(round(end.col + end.dc * s))
if not (0 <= r < h and 0 <= c < w):
return False
if dist_mask[r, c] <= end.half:
return True
return False
def _pair_ends(ends, passable):
"""Paare aufsteigend nach Winkelsumme; die Kurve muss über Maske oder sichtbarer Straße laufen."""
candidates = []
for i, a in enumerate(ends):
for j in range(i + 1, len(ends)):
b = ends[j]
vr, vc = b.row - a.row, b.col - a.col
if math.hypot(vr, vc) == 0:
continue
angle_a = _angle((a.dr, a.dc), (vr, vc))
angle_b = _angle((b.dr, b.dc), (-vr, -vc))
if angle_a > MAX_ANGLE or angle_b > MAX_ANGLE:
continue
if max(a.half, b.half) > WIDTH_RATIO * max(min(a.half, b.half), 0.5):
continue
candidates.append((angle_a + angle_b, i, j))
used, pairs = set(), []
for _, i, j in sorted(candidates):
if i in used or j in used or not _corridor(passable, _hermite(ends[i], ends[j])):
continue
used.update((i, j))
pairs.append((ends[i], ends[j]))
return pairs, [e for k, e in enumerate(ends) if k not in used]
def _corridor(passable, points) -> bool:
h, w = passable.shape
hits = [0 <= int(round(r)) < h and 0 <= int(round(c)) < w and passable[int(round(r)), int(round(c))]
for r, c in points]
return sum(hits) >= CORRIDOR_SHARE * len(hits)
def _hermite(a: RoadEnd, b: RoadEnd):
dist = math.hypot(b.row - a.row, b.col - a.col)
t = np.linspace(0.0, 1.0, max(3, 2 * int(math.ceil(dist)) + 1))
h00, h10 = 2 * t**3 - 3 * t**2 + 1, t**3 - 2 * t**2 + t
h01, h11 = -2 * t**3 + 3 * t**2, t**3 - t**2
rows = h00 * a.row + h10 * a.dr * dist + h01 * b.row - h11 * b.dr * dist
cols = h00 * a.col + h10 * a.dc * dist + h01 * b.col - h11 * b.dc * dist
return list(zip(rows, cols))
def _near_edge(end: RoadEnd, shape, edges) -> bool:
"""Liegt das Ende näher als EDGE_MARGIN an einer Fensterkante, die zugleich Rand des Ausschnitts ist?"""
top, bottom, left, right = edges
h, w = shape
return ((top and end.row < EDGE_MARGIN) or (bottom and end.row > h - 1 - EDGE_MARGIN)
or (left and end.col < EDGE_MARGIN) or (right and end.col > w - 1 - EDGE_MARGIN))
def _cast_rays(L, B, dist_mask, road, ends, centre, edges):
"""Weiterführen: (Ende, Länge, schließt an) je Ende ohne Partner.
„In der Maske“ heißt: Maske höchstens eine Halbbreite neben der Mittellinie, damit auch eine
seitlich angeschnittene Straße weiterläuft. Ein Anschluss ist eine gemalte Mittellinie in der
Maske (Kreuzung) oder sichtbare Straße der eigenen Klasse oder strasse höchstens eine Halbbreite
neben dem Strahl. Der Strahl läuft von Anschluss zu Anschluss weiter, solange der nächste
höchstens CROSS_THROUGH entfernt ist, und endet am letzten — im Straßenraster geht eine Straße
an einer Kreuzung meist weiter. Erreicht er in der Maske den Rand des Ausschnitts, schließt er
dort an: die Straße läuft über die Karte hinaus. Verlässt er die Maske auf anderem Boden, ist
Schluss; ohne jeden Anschluss ist das Ende eine Sackgasse.
"""
top, bottom, left, right = edges
h, w = dist_mask.shape
limit = math.hypot(h, w)
joinable = ((L == road) | (L == CLASSES["strasse"])) & ~B # Buchstabenkonturen zählen nicht
rays = []
for end in ends:
reach = int(math.ceil(end.half))
entered, joins, outside = None, [], 0.0
s = 0.0
while s <= limit:
r, c = int(round(end.row + end.dr * s)), int(round(end.col + end.dc * s))
if not (0 <= r < h and 0 <= c < w):
if entered is not None and ((r < 0 and top) or (r >= h and bottom) or (c < 0 and left) or (c >= w and right)):
joins.append(s - 0.5) # Rand des Ausschnitts: die Straße geht weiter
break
inside = dist_mask[r, c] <= end.half
if entered is None:
entered = s if inside else None
elif inside:
outside = 0.0
if centre[r, c] and dist_mask[r, c] == 0:
joins.append(s)
elif joinable[max(0, r - reach):r + reach + 1, max(0, c - reach):c + reach + 1].any():
joins.append(s)
outside += 0.5
if outside > 2 * end.half + 2:
break # fest an sichtbarer Straße angekommen
else:
break # anderer Boden: hier ist Schluss
if joins and s - joins[-1] > CROSS_THROUGH:
break
s += 0.5
if entered is not None:
rays.append([end, entered, joins[-1] if joins else s, bool(joins)])
events = []
for i in range(len(rays)):
for j in range(i + 1, len(rays)):
hit = _crossing(rays[i], rays[j])
if hit is not None:
events.append((max(hit), i, j, *hit))
for _, i, j, ti, tj in sorted(events):
if ti <= rays[i][2] and tj <= rays[j][2]:
rays[i][2:] = [ti, True]
rays[j][2:] = [tj, True]
return [(end, stop, joins) for end, _, stop, joins in rays]
def _crossing(ray_a, ray_b):
a, a_in, a_stop, _ = ray_a
b, b_in, b_stop, _ = ray_b
denom = a.dr * b.dc - a.dc * b.dr
if abs(denom) < 1e-9:
return None
qr, qc = b.row - a.row, b.col - a.col
ta = (qr * b.dc - qc * b.dr) / denom
tb = (qr * a.dc - qc * a.dr) / denom
if a_in <= ta <= a_stop and b_in <= tb <= b_stop:
return ta, tb
return None
def _stamp(L, M, painted, road, points, halves):
strip = np.zeros(M.shape, dtype=bool)
for (r, c), half in zip(points, halves):
rr, cc = disk((r, c), max(float(half), 0.5), shape=M.shape)
strip[rr, cc] = True
strip &= M
L[strip] = road
painted |= strip
def _mark_centre(centre, points):
h, w = centre.shape
for r, c in points:
r, c = int(round(r)), int(round(c))
centre[max(0, r - 1):min(h, r + 2), max(0, c - 1):min(w, c + 2)] = True
# --- Flächen -------------------------------------------------------------------------------------
def _fill_areas(L, target, B):
"""Nächste sichtbare Fläche; Stücke unter SOURCE_MIN_AREA (Kantenglättung) zählen nicht als Quelle."""
if not target.any():
return
source = np.zeros(L.shape, dtype=bool)
for name in AREA_CLASSES:
area = (L == CLASSES[name]) & ~B
comps, count = ndi.label(area)
if count:
sizes = ndi.sum(area, comps, np.arange(1, count + 1))
source |= np.isin(comps, 1 + np.flatnonzero(sizes >= SOURCE_MIN_AREA))
if not source.any():
L[target] = CLASSES["stadt"]
return
fill_nearest(L, target, ~source)
# --- Hilfen -----------------------------------------------------------------------------------
def _window(mask, margin):
rows, cols = np.nonzero(mask)
h, w = mask.shape
return (slice(max(0, int(rows.min()) - margin), min(h, int(rows.max()) + 1 + margin)),
slice(max(0, int(cols.min()) - margin), min(w, int(cols.max()) + 1 + margin)))
def _length(path) -> float:
return sum(math.dist(path[i - 1], path[i]) for i in range(1, len(path)))
def _cos(a, b) -> float:
na, nb = math.hypot(*a), math.hypot(*b)
return (a[0] * b[0] + a[1] * b[1]) / (na * nb) if na and nb else -1.0
def _angle(a, b) -> float:
return math.degrees(math.acos(max(-1.0, min(1.0, _cos(a, b)))))
+164
View File
@@ -0,0 +1,164 @@
import numpy as np
from scipy import ndimage as ndi
from leonida.classify import CLASSES
from leonida.inpaint import fill_nearest, is_thin, rebuild
STADT, PARK, WATER = CLASSES["stadt"], CLASSES["gruen"], CLASSES["wasser"]
ROAD, PATH, BUILDING = CLASSES["strasse"], CLASSES["weg"], CLASSES["gebaeude"]
def city(h=200, w=300):
return np.full((h, w), STADT, dtype=np.uint8)
def box_mask(shape, rows, cols):
mask = np.zeros(shape, dtype=bool)
mask[rows, cols] = True
return mask
def scribble(labels, mask):
"""Schrift simulieren: unter der Maske steht Unsinn, der nichts mit der Karte zu tun hat."""
labels[mask] = BUILDING
labels[mask & (np.indices(labels.shape).sum(axis=0) % 3 == 0)] = ROAD
def test_fill_nearest_keeps_a_road_running_along_a_grid_line():
labels = city(60, 100)
labels[:, 40:60] = ROAD
band = box_mask(labels.shape, slice(None), slice(47, 53))
labels[band] = BUILDING
fill_nearest(labels, band, band)
assert (labels[:, 40:60] == ROAD).all()
def test_fill_nearest_closes_a_grid_slit_in_a_park():
labels = np.full((60, 100), PARK, dtype=np.uint8)
band = box_mask(labels.shape, slice(28, 34), slice(None))
labels[band] = ROAD
fill_nearest(labels, band, band)
assert (labels == PARK).all()
def test_thin_means_at_most_four_metres_thick():
assert is_thin(box_mask((40, 40), slice(10, 14), slice(5, 35)))
assert not is_thin(box_mask((40, 40), slice(10, 15), slice(5, 35)))
def test_straight_road_runs_through_a_text_band_in_the_same_width():
labels = city()
labels[:, 140:160] = ROAD
mask = box_mask(labels.shape, slice(80, 120), slice(60, 240))
scribble(labels, mask)
result = rebuild(labels, mask)
assert result.stats.connected == 1
assert (labels[80:120, 141:159] == ROAD).all()
assert not (labels[80:120, 60:138] == ROAD).any()
assert not (labels[80:120, 162:240] == ROAD).any()
def test_a_slight_offset_is_bridged_smoothly():
labels = city()
labels[:100, 130:150] = ROAD
labels[100:, 138:158] = ROAD
mask = box_mask(labels.shape, slice(80, 120), slice(60, 240))
scribble(labels, mask)
result = rebuild(labels, mask)
assert result.stats.connected == 1
roads, count = ndi.label(labels == ROAD)
assert count == 1 # oben und unten hängen jetzt zusammen
assert 16 <= (labels[100] == ROAD).sum() <= 24 # in der Mitte etwa straßenbreit
def test_roads_across_visible_ground_are_not_joined():
labels = city()
labels[90:110, :60] = ROAD
labels[90:110, 240:] = ROAD
mask = box_mask(labels.shape, slice(70, 130), slice(60, 140)) | box_mask(labels.shape, slice(70, 130), slice(180, 240))
mask |= box_mask(labels.shape, slice(70, 75), slice(140, 180)) # schmale Brücke: eine Maske
scribble(labels, mask)
result = rebuild(labels, mask)
assert result.stats.connected == 0
assert result.stats.dead_ends == 2
assert (labels[90:110, 141:179] == STADT).all() # sichtbarer Boden dazwischen bleibt
assert not (labels[mask] == ROAD).any() # Sackgassen werden nicht gemalt
def test_a_corner_under_the_mask_has_no_overhang():
labels = city()
labels[:80, 100:120] = ROAD
labels[130:150, 181:] = ROAD
mask = box_mask(labels.shape, slice(80, 180), slice(60, 181))
scribble(labels, mask)
result = rebuild(labels, mask)
assert result.stats.extended == 2
assert (labels[80:130, 102:118] == ROAD).all()
assert (labels[132:148, 122:181] == ROAD).all()
assert not (labels[153:180, 100:120] == ROAD).any()
assert not (labels[130:150, 60:97] == ROAD).any()
def test_a_side_street_joins_a_hidden_through_road():
labels = city()
labels[:80, 140:160] = ROAD
labels[130:150, :] = ROAD
mask = box_mask(labels.shape, slice(80, 180), slice(60, 240))
scribble(labels, mask)
result = rebuild(labels, mask)
assert result.stats.connected == 1 and result.stats.extended == 1
assert (labels[80:131, 142:158] == ROAD).all()
assert not (labels[153:180, 140:160] == ROAD).any()
def test_a_road_clipped_on_one_side_keeps_its_full_width():
labels = city()
labels[:, 140:160] = ROAD
mask = box_mask(labels.shape, slice(80, 120), slice(150, 240))
scribble(labels, mask)
rebuild(labels, mask)
assert (labels[80:120, 140:159] == ROAD).all()
assert not (labels[80:120, 162:240] == ROAD).any()
def test_a_path_stays_a_path_and_the_street_wins_the_crossing():
labels = city()
labels[:, 145:150] = PATH
labels[95:115, :] = ROAD
mask = box_mask(labels.shape, slice(60, 140), slice(100, 200))
scribble(labels, mask)
rebuild(labels, mask)
assert (labels[97:113, 145:150] == ROAD).all()
assert (labels[62:90, 146:149] == PATH).all()
assert (labels[120:138, 146:149] == PATH).all()
def test_park_and_water_under_text_stay_what_they_are():
labels = city()
labels[:, :150] = PARK
labels[:, 250:] = WATER
mask = box_mask(labels.shape, slice(80, 120), slice(100, 280))
scribble(labels, mask)
rebuild(labels, mask)
assert (labels[80:120, 100:149] == PARK).all()
assert (labels[80:120, 151:249] == STADT).all()
assert (labels[80:120, 251:280] == WATER).all()
def test_edge_crumbs_are_no_fill_source():
labels = np.full((200, 300), PARK, dtype=np.uint8)
labels[74:78, 150:154] = CLASSES["sand"] # 16 m² Kantenglättung
mask = box_mask(labels.shape, slice(80, 120), slice(100, 200))
scribble(labels, mask)
rebuild(labels, mask)
assert (labels[mask] == PARK).all()
def test_a_road_running_off_the_map_under_text_is_kept_to_the_edge():
labels = city()
labels[:, 140:160] = ROAD
mask = box_mask(labels.shape, slice(0, 30), slice(60, 240))
scribble(labels, mask)
result = rebuild(labels, mask)
assert result.stats.extended == 1
assert (labels[0:30, 142:158] == ROAD).all()