Files
jetlag-maps/copenhagen/scripts/render.py
T
marvin 9e078efad3 Merge bus stops into rail stations when names match
Bus stops sharing a name with a rail station (metro/s_tog/regional) are
now skipped from bus stop markers — the rail station marker represents
that stop. 32 bus stops across 9 names (Fasanvej, Frederiksberg Allé,
Jyllingevej, Mozarts Plads, Peter Bangs Vej, Ryparken, Rådhuspladsen,
Sluseholmen, Vigerslev Allé) are absorbed.
2026-09-13 23:26:19 +02:00

437 lines
16 KiB
Python

#!/usr/bin/env python3
"""Render the Copenhagen transit map to PNG/SVG/PDF.
Reads data/processed/master.gpkg (layers: lines, stations) + area.gpkg +
config/styling.yaml, applies CLI filters, and composes a printable map:
- Carto Positron (no labels) basemap via contextily (EPSG:3857)
- two-tone lines: dark casing + colour body, per route
- z-order: bus (bottom) -> s_tog -> light_rail -> regional -> metro (top)
- shapeburst fade mask outside the City Pass area
- station labels (metro + S-tog) via adjustText
Usage:
uv run python render.py # default: all modes, PNG, markers only
uv run python render.py --labels # add station name labels
uv run python render.py --no-stops # no station markers
uv run python render.py --no-buses --format svg --out map.svg
uv run python render.py --bus-subset A,C,S # only trunk buses
uv run python render.py --modes metro,s_tog # rail-only
"""
import argparse
from pathlib import Path
import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import yaml
from affine import Affine
from adjustText import adjust_text
from scipy.ndimage import distance_transform_edt
from _common import CONFIG, OUTPUT, PROCESSED, UA
TARGET_CRS = "EPSG:3857"
def load_args():
p = argparse.ArgumentParser(description="Render the Copenhagen transit map.")
p.add_argument("--modes", default=None,
help="comma-separated styles to include (metro,s_tog,light_rail,regional,bus)")
p.add_argument("--no-buses", action="store_true", help="omit bus layer")
p.add_argument("--bus-subset", default=None,
help="comma-separated bus categories to keep (A,C,S,regular)")
p.add_argument("--max-bus-routes", type=int, default=None,
help="cap on number of distinct bus refs drawn")
p.add_argument("--dpi", type=int, default=200)
p.add_argument("--size", default="16",
help="figure size in inches: 'W' (height auto) or 'WxH'")
p.add_argument("--format", default="png", help="output format (png/svg/pdf)")
p.add_argument("--out", default=None, help="output path (default: output/copenhagen.<fmt>)")
p.add_argument("--no-basemap", action="store_true", help="skip basemap tiles (offline/faster)")
p.add_argument("--labels", action="store_true", help="draw station name labels (off by default)")
p.add_argument("--no-stops", action="store_true", help="skip station markers")
return p.parse_args()
def parse_size(spec, aspect):
if "x" in spec:
w, h = spec.split("x")
return float(w), float(h)
w = float(spec)
return w, w / aspect
def filter_lines(lines, args):
keep = set(lines["style"].unique())
if args.modes:
keep &= {m.strip() for m in args.modes.split(",")}
if args.no_buses:
keep.discard("bus")
out = lines[lines["style"].isin(keep)].copy()
if "bus" in keep and out["style"].eq("bus").any():
bus = out[out["style"].eq("bus")]
other = out[~out["style"].eq("bus")]
if args.bus_subset:
cats = {c.strip() for c in args.bus_subset.split(",")}
bus = bus[bus["bus_category"].isin(cats)]
if args.max_bus_routes is not None:
refs = list(dict.fromkeys(bus["ref"].dropna()))
keep_refs = set(refs[:args.max_bus_routes])
bus = bus[bus["ref"].isin(keep_refs)]
out = gpd.GeoDataFrame(pd_concat([other, bus]), crs=out.crs)
return out
def pd_concat(frames):
import pandas as pd
return pd.concat(frames, ignore_index=True)
def add_basemap(ax, styling, zoom=None):
if zoom is None:
zoom = styling["basemap"]["zoom"]
try:
import contextily as cx
provider = styling["basemap"]["provider"]
src = cx.providers
for part in provider.split("."):
src = getattr(src, part)
cx.add_basemap(ax, crs=TARGET_CRS, source=src, zoom=zoom,
attribution=False, zorder=0,
headers={"User-Agent": UA})
return True
except Exception as e:
print(f" [basemap] unavailable ({e}); using plain background", flush=True)
ax.set_facecolor("#f2f2f0")
return False
def shapeburst_mask(ax, area_3857, extent, styling, res=1600):
fade_m = styling["mask"]["fade_distance_m"]
max_alpha = styling["mask"]["max_alpha"]
color = styling["mask"]["color"]
x0, x1, y0, y1 = extent
nx = res
ny = max(1, int(round(res * (y1 - y0) / (x1 - x0))))
pxw = (x1 - x0) / nx
pxh = (y1 - y0) / ny
transform = Affine.translation(x0, y1) * Affine.scale(pxw, -pxh)
from rasterio.features import rasterize
geom = area_3857.geometry.union_all()
mask = rasterize([(geom, 1)], out_shape=(ny, nx), transform=transform,
fill=0, dtype=np.uint8, all_touched=False).astype(bool)
dist_px = distance_transform_edt(~mask)
dist_m = dist_px * pxw
alpha = np.where(mask, 0.0, np.clip(dist_m / fade_m, 0.0, 1.0)) * max_alpha
cmap = matplotlib.colors.to_rgba(color)
rgba = np.zeros((ny, nx, 4))
rgba[..., :3] = cmap[:3]
rgba[..., 3] = alpha
ax.imshow(rgba, extent=(x0, x1, y0, y1), origin="upper",
aspect="equal", interpolation="bilinear", zorder=0.5)
def plot_lines(ax, lines, styling):
zord = styling["zorder"]
widths = styling["line_width"]
alphas = styling["alpha"]
outline_w = styling["outline_width"]
outline_c = styling["outline_color"]
for style in sorted(zord, key=lambda k: zord[k]):
grp = lines[lines["style"] == style]
if grp.empty:
continue
w = widths.get(style, 1.0)
z = zord[style]
# dark casing
grp.plot(ax=ax, color=outline_c, linewidth=w + outline_w,
zorder=z, alpha=1.0, capstyle="round", joinstyle="round")
# colour body
body_colors = grp["colour_final"].tolist()
grp.plot(ax=ax, color=body_colors, linewidth=w,
zorder=z + 0.1, alpha=alphas.get(style, 1.0),
capstyle="round", joinstyle="round")
def classify_station(r):
"""Return the style key for a station row, or None."""
if r.get("subway") == "yes" or r.get("station") == "subway":
return "metro"
if r.get("light_rail") == "yes" or r.get("station") == "light_rail":
return "s_tog"
if r.get("railway") == "station" or r.get("train") == "yes":
return "regional"
return None
def cluster_stops(coords, threshold_m):
"""Cluster (x, y) coordinates within threshold_m; return list of centroids."""
from scipy.cluster.hierarchy import fcluster, linkage
from scipy.spatial.distance import pdist
coords = np.asarray(coords)
if len(coords) <= 1:
return coords.tolist() if len(coords) else []
dists = pdist(coords)
links = linkage(dists, method="single")
labels = fcluster(links, t=threshold_m, criterion="distance")
centroids = []
for label in set(labels):
members = coords[labels == label]
centroids.append(members.mean(axis=0).tolist())
return centroids
def plot_stations(ax, stations, styling, active_styles):
if stations is None or stations.empty:
return
cfg = styling.get("stations", {})
if not cfg.get("show", True):
return
marker_styles = set(cfg.get("styles", ["metro", "s_tog", "regional"]))
cluster_cfg = cfg.get("stop_cluster_m", {})
mk = cfg.get("marker", {})
sizes = mk.get("size", {})
shape = mk.get("shape", "circle")
fill = mk.get("fill", "white")
edge = mk.get("edge", "#2b2b2b")
lw = mk.get("linewidth", 0.8)
marker = "o" if shape == "circle" else "s"
# group coordinates by style, then by name for clustering
pts_by_style = {}
names_by_style = {}
for _, r in stations.iterrows():
s = classify_station(r)
if s is None or s not in marker_styles or s not in active_styles:
continue
coord = (r.geometry.x, r.geometry.y)
name = r.get("name") if isinstance(r.get("name"), str) else None
pts_by_style.setdefault(s, []).append(coord)
names_by_style.setdefault(s, {}).setdefault(name, []).append(coord)
zord = styling["zorder"]
top_z = max(zord.values()) + 1 # all markers above all lines
for s in sorted(pts_by_style, key=lambda k: zord.get(k, 0)):
threshold = cluster_cfg.get(s, 50) if isinstance(cluster_cfg, dict) else cluster_cfg
# cluster same-name stations to one marker; keep unnamed as-is
centroids = []
for name, coords in names_by_style[s].items():
if name and len(coords) > 1:
centroids.extend(cluster_stops(coords, threshold))
else:
centroids.extend(coords)
sz = sizes.get(s, 3.0)
ax.scatter([p[0] for p in centroids], [p[1] for p in centroids],
s=sz ** 2, marker=marker, c=fill, edgecolors=edge,
linewidths=lw, zorder=top_z, alpha=1.0)
def rail_station_names(stations):
"""Return the set of names for rail stations (metro/s_tog/regional)."""
if stations is None or stations.empty:
return set()
names = set()
for _, r in stations.iterrows():
if classify_station(r) is not None:
name = r.get("name")
if isinstance(name, str) and name.strip():
names.add(name)
return names
def plot_bus_stops(ax, stops, styling, active_styles, rail_names=None):
"""Draw small markers for bus stops from the stops layer.
Bus stops whose name matches a rail station are skipped — the rail
station marker represents that stop.
"""
if stops is None or stops.empty or "bus" not in active_styles:
return
cfg = styling.get("stations", {})
if not cfg.get("show", True) or "bus" not in set(cfg.get("styles", [])):
return
bus = stops[stops["style"] == "bus"]
if bus.empty:
return
if rail_names is None:
rail_names = set()
cluster_cfg = cfg.get("stop_cluster_m", {})
cluster_m = cluster_cfg.get("bus", 50) if isinstance(cluster_cfg, dict) else cluster_cfg
mk = cfg.get("marker", {})
sizes = mk.get("size", {})
sz = sizes.get("bus", 1.5)
fill = mk.get("fill", "white")
edge = mk.get("edge", "#2b2b2b")
lw = mk.get("linewidth", 0.8)
shape = mk.get("shape", "circle")
marker = "o" if shape == "circle" else "s"
top_z = max(styling["zorder"].values()) + 1
# cluster same-name bus stops within threshold (opposite sides of road)
# skip names that match a rail station (merged into station marker)
centroids = []
skipped = 0
for name, grp in bus.groupby("name"):
if name and name in rail_names:
skipped += len(grp)
continue
coords = [(r.geometry.x, r.geometry.y) for _, r in grp.iterrows()]
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],
s=sz ** 2, marker=marker, c=fill, edgecolors=edge,
linewidths=lw, zorder=top_z, alpha=0.8)
def label_stations(ax, stations, styling, active_styles):
if stations is None or stations.empty:
return
cfg = styling.get("labels", {})
if not cfg.get("show", False):
return
label_styles = set(cfg.get("styles", ["metro", "s_tog"])) & active_styles
fmin = cfg.get("min_fontsize", 5)
fmax = cfg.get("max_fontsize", 9)
def is_metro(r):
return classify_station(r) == "metro"
def is_stog(r):
return classify_station(r) == "s_tog"
pts = []
for _, r in stations.iterrows():
s = classify_station(r)
if s not in label_styles:
continue
name = r.get("name")
if not isinstance(name, str) or not name.strip():
continue
pts.append((r.geometry.x, r.geometry.y, name, s == "metro"))
if not pts:
return
# dedupe by name (keep first location)
seen = {}
uniq = []
for x, y, name, is_m in pts:
if name in seen:
continue
seen[name] = True
uniq.append((x, y, name, is_m))
texts = []
for x, y, name, is_m in uniq:
fs = fmax if is_m else fmin
bbox = dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.7)
t = ax.text(x, y, name, fontsize=fs, color="#111111",
zorder=10, ha="center", va="center", bbox=bbox)
texts.append(t)
try:
adjust_text(texts, ax=ax, expand_points=(1.4, 1.6),
force_text=(0.4, 0.6), lim=300,
arrowprops=dict(arrowstyle="-", color="#888", lw=0.4))
except Exception as e:
print(f" [labels] adjustText failed ({e}); labels may overlap", flush=True)
def main():
args = load_args()
styling = yaml.safe_load((CONFIG / "styling.yaml").read_text())
area = gpd.read_file(PROCESSED / "area.gpkg").to_crs(TARGET_CRS)
lines = gpd.read_file(PROCESSED / "master.gpkg", layer="lines").to_crs(TARGET_CRS)
try:
stations = gpd.read_file(PROCESSED / "master.gpkg", layer="stations").to_crs(TARGET_CRS)
except Exception:
stations = None
try:
stops = gpd.read_file(PROCESSED / "master.gpkg", layer="stops").to_crs(TARGET_CRS)
except Exception:
stops = None
lines = filter_lines(lines, args)
print(f"rendering {len(lines)} line features", flush=True)
bounds = area.total_bounds # (minx, miny, maxx, maxy)
dx = bounds[2] - bounds[0]
dy = bounds[3] - bounds[1]
aspect = dx / dy
pad = 0.12
extent = (bounds[0] - pad * dx, bounds[2] + pad * dx,
bounds[1] - pad * dy, bounds[3] + pad * dy)
fig_w, fig_h = parse_size(args.size, aspect)
fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=args.dpi)
ax.set_xlim(extent[0], extent[1])
ax.set_ylim(extent[2], extent[3])
ax.set_aspect("equal")
ax.axis("off")
# basemap
if not args.no_basemap:
add_basemap(ax, styling)
# fade mask outside area
shapeburst_mask(ax, area, extent, styling)
# lines
plot_lines(ax, lines, styling)
# active styles (which mode layers are actually drawn)
active_styles = set(lines["style"].unique())
# station markers (symbols, no text)
if not args.no_stops:
plot_stations(ax, stations, styling, active_styles)
rail_names = rail_station_names(stations)
plot_bus_stops(ax, stops, styling, active_styles, rail_names=rail_names)
# station labels (opt-in via --labels)
if args.labels:
styling["labels"]["show"] = True
if styling.get("labels", {}).get("show", False):
label_stations(ax, stations, styling, active_styles)
# title + attribution
ax.text(0.5, 0.985, "Kurragömma Köpenhamn",
transform=ax.transAxes, ha="center", va="top",
fontsize=fig_w * 1.1, fontweight="bold", color="#222",
bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="none", alpha=0.7))
ax.text(0.01, 0.01,
"Data: © OpenStreetMap contributors (ODbL) · Base: Esri, HERE",
transform=ax.transAxes, ha="left", va="bottom",
fontsize=max(5, fig_w * 0.5), color="#666")
out_dir = OUTPUT
out_dir.mkdir(parents=True, exist_ok=True)
if args.out:
out_path = Path(args.out)
if not out_path.is_absolute():
out_path = out_dir / out_path
else:
out_path = out_dir / f"copenhagen.{args.format}"
out_path.parent.mkdir(parents=True, exist_ok=True)
plt.savefig(out_path, dpi=args.dpi, bbox_inches="tight",
pad_inches=0.2, facecolor="white")
plt.close(fig)
print(f"wrote {out_path} ({args.format}, {fig_w}x{fig_h}in @ {args.dpi}dpi)")
if __name__ == "__main__":
main()