Use platform nodes + route membership for bus stop clustering

Replaces distance-based clustering with route-membership grouping:
- download_osm.py: collect route_refs per stop node; use platform roles
  for bus stops, stop roles for rail stops
- prepare.py: carry route_refs through to master.gpkg
- render.py: group_by_route_membership() uses union-find to cluster
  same-name stops sharing at least one route into one marker; stops on
  disjoint routes are kept separate. No distance threshold needed.

Results: 786 bus platforms -> 465 markers (was 1610 -> 781).
Veksøvej: 2 platforms sharing routes 2A+5C -> 1 marker.
Peter Bangs Vej: 7 platforms on routes 10/21/22/4A/7A -> 2 markers.
This commit is contained in:
marvin
2026-09-14 00:00:28 +02:00
parent 977a3ffb4c
commit dccf04349a
3 changed files with 106 additions and 29 deletions
+30 -7
View File
@@ -8,7 +8,8 @@ fallback + exponential backoff) and writes:
Lines: one MultiLineString per route relation (concatenation of member way Lines: one MultiLineString per route relation (concatenation of member way
geometries), carrying ref/name/network/colour/operator/route/mode tags. geometries), carrying ref/name/network/colour/operator/route/mode tags.
Stops: one Point per stop/platform node member, carrying ref/name/mode. Stops: one Point per platform node member, carrying ref/name/mode/route_refs
(route refs of routes using this stop, for disambiguation).
""" """
import sys import sys
import time import time
@@ -28,7 +29,11 @@ MIRRORS = [
] ]
PLATFORM_ROLES = {"platform", "platform_entry", "platform_exit"} PLATFORM_ROLES = {"platform", "platform_entry", "platform_exit"}
STOP_ROLES = {"stop", "stop_entry", "stop_exit", "platform", "platform_entry", "platform_exit"} STOP_ROLES = {"stop", "stop_entry", "stop_exit", "stop_entry_only", "stop_exit_only"}
# bus stops: use platform nodes (where passengers wait)
# rail stops: use stop nodes (platforms are often areas, stop nodes are reliable points)
BUS_STOP_ROLES = PLATFORM_ROLES
RAIL_STOP_ROLES = STOP_ROLES | PLATFORM_ROLES
def build_query(mode_cfg, bbox): def build_query(mode_cfg, bbox):
@@ -72,19 +77,25 @@ def overpass(ql):
def parse(data, mode): def parse(data, mode):
is_bus = mode == "bus"
stop_roles = BUS_STOP_ROLES if is_bus else RAIL_STOP_ROLES
nodes = {} nodes = {}
for e in data["elements"]: for e in data["elements"]:
if e["type"] == "node": if e["type"] == "node":
nodes[e["id"]] = e nodes[e["id"]] = e
lines, stops = [], [] lines = []
stop_seen = set() # collect route_refs per stop node: {node_id: set(route_refs)}
node_routes = {}
for e in data["elements"]: for e in data["elements"]:
if e["type"] != "relation" or "tags" not in e: if e["type"] != "relation" or "tags" not in e:
continue continue
tags = e["tags"] tags = e["tags"]
if tags.get("route") is None: if tags.get("route") is None:
continue continue
route_ref = tags.get("ref")
# collect line geometries
segs = [] segs = []
for m in e["members"]: for m in e["members"]:
if m["type"] == "way" and "geometry" in m and m.get("role") not in PLATFORM_ROLES: if m["type"] == "way" and "geometry" in m and m.get("role") not in PLATFORM_ROLES:
@@ -96,7 +107,7 @@ def parse(data, mode):
continue continue
lines.append({ lines.append({
"mode": mode, "mode": mode,
"ref": tags.get("ref"), "ref": route_ref,
"name": tags.get("name"), "name": tags.get("name"),
"network": tags.get("network"), "network": tags.get("network"),
"colour": tags.get("colour"), "colour": tags.get("colour"),
@@ -104,11 +115,22 @@ def parse(data, mode):
"route": tags.get("route"), "route": tags.get("route"),
"geometry": geom, "geometry": geom,
}) })
# record route_ref on each stop member node
for m in e["members"]: for m in e["members"]:
if m["type"] != "node" or m.get("role") not in STOP_ROLES: if m["type"] != "node" or m.get("role") not in stop_roles:
continue continue
nid = m["ref"] nid = m["ref"]
if nid not in nodes or nid in stop_seen: if nid not in nodes:
continue
node_routes.setdefault(nid, set())
if route_ref:
node_routes[nid].add(route_ref)
# build stops from collected nodes
stops = []
stop_seen = set()
for nid, routes in node_routes.items():
if nid in stop_seen:
continue continue
nd = nodes[nid] nd = nodes[nid]
if "lat" not in nd or "lon" not in nd: if "lat" not in nd or "lon" not in nd:
@@ -121,6 +143,7 @@ def parse(data, mode):
"ref": ntags.get("ref"), "ref": ntags.get("ref"),
"public_transport": ntags.get("public_transport"), "public_transport": ntags.get("public_transport"),
"railway": ntags.get("railway"), "railway": ntags.get("railway"),
"route_refs": ";".join(sorted(routes)),
"geometry": Point(nd["lon"], nd["lat"]), "geometry": Point(nd["lon"], nd["lat"]),
}) })
return lines, stops return lines, stops
+1 -1
View File
@@ -171,7 +171,7 @@ def main():
if stops is not None and not stops.empty: if stops is not None and not stops.empty:
stops = stops[~stops.geometry.isna() & stops.geometry.is_valid] stops = stops[~stops.geometry.isna() & stops.geometry.is_valid]
stops_clipped = gpd.clip(stops, area_geom) stops_clipped = gpd.clip(stops, area_geom)
keep = ["mode", "style", "name", "ref", "public_transport", "railway", "geometry"] keep = ["mode", "style", "name", "ref", "public_transport", "railway", "route_refs", "geometry"]
for c in keep: for c in keep:
if c not in stops_clipped.columns: if c not in stops_clipped.columns:
stops_clipped[c] = None stops_clipped[c] = None
+62 -8
View File
@@ -262,11 +262,70 @@ def rail_station_names(stations):
return names return names
def group_by_route_membership(rows, cluster_m):
"""Group stop rows by (name, shared route_ref) into distinct stops.
Two same-name stops that share at least one route_ref are the same stop.
Stops on disjoint routes are different stops. Within each group,
platform duplicates are merged by distance clustering.
Returns a list of (x, y) centroids.
"""
if rows is None or rows.empty:
return []
coords = [(r.geometry.x, r.geometry.y) for _, r in rows.iterrows()]
route_sets = []
for _, r in rows.iterrows():
rr = r.get("route_refs")
if isinstance(rr, str) and rr:
route_sets.append(set(rr.split(";")))
else:
route_sets.append(set())
n = len(coords)
# union-find: stops sharing a route are connected
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
for i in range(n):
for j in range(i + 1, n):
if route_sets[i] & route_sets[j]:
union(i, j)
# group indices by connected component
components = {}
for i in range(n):
root = find(i)
components.setdefault(root, []).append(i)
centroids = []
for indices in components.values():
comp_coords = [coords[i] for i in indices]
if len(comp_coords) == 1:
centroids.append(comp_coords[0])
else:
# stops sharing a route are the same stop; merge to centroid
import numpy as np
centroids.append(tuple(np.mean(comp_coords, axis=0)))
return centroids
def plot_bus_stops(ax, stops, styling, active_styles, rail_names=None): def plot_bus_stops(ax, stops, styling, active_styles, rail_names=None):
"""Draw small markers for bus stops from the stops layer. """Draw small markers for bus stops from the stops layer.
Bus stops whose name matches a rail station are skipped — the rail Bus stops whose name matches a rail station are skipped — the rail
station marker represents that stop. station marker represents that stop. Stops are grouped by shared
route membership: same-name stops on disjoint routes are distinct.
""" """
if stops is None or stops.empty or "bus" not in active_styles: if stops is None or stops.empty or "bus" not in active_styles:
return return
@@ -292,19 +351,14 @@ def plot_bus_stops(ax, stops, styling, active_styles, rail_names=None):
marker = "o" if shape == "circle" else "s" marker = "o" if shape == "circle" else "s"
top_z = max(styling["zorder"].values()) + 1 top_z = max(styling["zorder"].values()) + 1
# cluster same-name bus stops within threshold (opposite sides of road) # group by name, then by route membership; skip rail station names
# skip names that match a rail station (merged into station marker)
centroids = [] centroids = []
skipped = 0 skipped = 0
for name, grp in bus.groupby("name"): for name, grp in bus.groupby("name"):
if name and name in rail_names: if name and name in rail_names:
skipped += len(grp) skipped += len(grp)
continue continue
coords = [(r.geometry.x, r.geometry.y) for _, r in grp.iterrows()] centroids.extend(group_by_route_membership(grp, cluster_m))
if name and len(coords) > 1:
centroids.extend(cluster_stops(coords, cluster_m))
else:
centroids.extend(coords)
ax.scatter([p[0] for p in centroids], [p[1] for p in centroids], ax.scatter([p[0] for p in centroids], [p[1] for p in centroids],
s=sz ** 2, marker=marker, c=fill, edgecolors=edge, s=sz ** 2, marker=marker, c=fill, edgecolors=edge,