Copenhagen Phase 3: render.py -> PNG/SVG/PDF map
- render.py: parameterized CLI (--modes, --no-buses, --bus-subset, --max-bus-routes, --dpi, --size, --format, --out, --no-basemap, --no-labels) - basemap: OSM standard tiles via contextily (EPSG:3857) - two-tone lines: dark casing + colour body per route, z-order bus->s_tog->light_rail->regional->metro - shapeburst fade mask outside City Pass area (distance-transform alpha) - station labels for metro + S-tog via adjustText - title + attribution - styling.yaml: switch basemap to OpenStreetMap.Mapnik (CartoDB now needs API key) - .gitignore: exclude generated outputs (regenerable via render.py) - verified: full map, --no-buses, --bus-subset, --modes, SVG all work
This commit is contained in:
@@ -4,6 +4,11 @@ copenhagen/data/processed/**
|
||||
!copenhagen/data/raw/.gitkeep
|
||||
!copenhagen/data/processed/.gitkeep
|
||||
|
||||
# Generated map outputs (regenerable via render.py)
|
||||
copenhagen/output/*.png
|
||||
copenhagen/output/*.svg
|
||||
copenhagen/output/*.pdf
|
||||
|
||||
# Python
|
||||
.venv/
|
||||
__pycache__/
|
||||
|
||||
@@ -67,7 +67,7 @@ mask:
|
||||
color: white
|
||||
|
||||
basemap:
|
||||
provider: CartoDB.PositronNoLabels
|
||||
provider: OpenStreetMap.Mapnik
|
||||
zoom: 14
|
||||
|
||||
labels:
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
#!/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
|
||||
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
|
||||
|
||||
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("--no-labels", action="store_true", help="skip station labels")
|
||||
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)
|
||||
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 label_stations(ax, stations, styling, area_3857):
|
||||
if stations is None or stations.empty:
|
||||
return
|
||||
label_styles = set(styling["labels"]["styles"])
|
||||
fmin = styling["labels"]["min_fontsize"]
|
||||
fmax = styling["labels"]["max_fontsize"]
|
||||
|
||||
def is_metro(r):
|
||||
return r.get("subway") == "yes" or r.get("station") == "subway"
|
||||
|
||||
def is_stog(r):
|
||||
return r.get("light_rail") == "yes" or r.get("station") == "light_rail"
|
||||
|
||||
pts = []
|
||||
for _, r in stations.iterrows():
|
||||
is_m = is_metro(r)
|
||||
is_s = is_stog(r)
|
||||
if "metro" in label_styles and is_m:
|
||||
pass
|
||||
elif "s_tog" in label_styles and is_s:
|
||||
pass
|
||||
else:
|
||||
continue
|
||||
name = r.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
pts.append((r.geometry.x, r.geometry.y, name, is_m))
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
# labels
|
||||
if not args.no_labels and styling["labels"]["show"]:
|
||||
label_stations(ax, stations, styling, area)
|
||||
|
||||
# title + attribution
|
||||
ax.text(0.5, 0.985, "Copenhagen — Public Transit",
|
||||
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: © CARTO",
|
||||
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()
|
||||
Reference in New Issue
Block a user