How OpenSidewalks NYC is pieced together¶

Brownsville demo · Repository · Download

This notebook follows a few blocks of Washington Heights in Manhattan from the public sources to the published graph. Each step shows a raw source, the pipeline function that turns it into OpenSidewalks Nodes and Edges, and what that step left in the published release.

It reads the published release over the network, a few blocks at a time, so it does not need the full build (about an hour; peak memory 36 GB, which ran with swap on a 32 GB machine). It describes v0.3.7-nyc.1 and reads the latest release. To read a local copy instead, set the environment variable OSW_NYC_FGB to the path of nyc-osw.fgb. It calls the same functions as python -m pipeline build, imported from this repository.

This project has not checked any of the data on the ground. Read the Disclaimer at the end before using it. The evidence behind its numbers, and what that evidence does not show, is in evaluation/. Questions and corrections go to the project's GitHub issues.

Map data in every figure: © OpenStreetMap contributors (ODbL), NYC DOT, NYC OTI, NY State GIS and NOAA.

To run it yourself:

git clone https://github.com/msradam/opensidewalks-nyc && cd opensidewalks-nyc
uv venv --python 3.11 && source .venv/bin/activate
uv pip install -e ".[notebook]"
jupyter lab notebooks/how-it-works.ipynb
In [1]:
import importlib.util, io, json, os, sys, tempfile, time, warnings
from urllib.parse import quote
from pathlib import Path

import geopandas as gpd
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import pandas as pd
import rasterio
import requests
from matplotlib.patches import Patch
from shapely.geometry import LineString

REPO = Path.cwd().resolve()
if REPO.name == "notebooks":
    REPO = REPO.parent
sys.path.insert(0, str(REPO))
warnings.filterwarnings("ignore", category=UserWarning)

from pipeline.stages.schema_map import _classify_osm_edge, _ramps_to_curb_nodes, _join_widths_from_planimetric
from pipeline.stages.assemble import _endpoint_coords, _ring_rows, _snap_curb_nodes
from pipeline.stages.acquire import _fetch_dem_tile
from pipeline.utils.zones import zone_edges

def load_module(path):
    spec = importlib.util.spec_from_file_location(Path(path).stem.replace("-", "_"), REPO / path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod

osw_to_unweaver = load_module("scripts/osw_to_unweaver.py")
cost_wheelchair = load_module("unweaver-project/cost-wheelchair.py")

# The latest release, read over the network. Set OSW_NYC_FGB to a local path or another URL to read that instead.
LATEST = "https://github.com/msradam/opensidewalks-nyc/releases/latest/download/nyc-osw.fgb"
RELEASE = os.environ.get("OSW_NYC_FGB") or LATEST
RELEASE = "/vsicurl/" + RELEASE if RELEASE.startswith("http") else str(REPO / RELEASE)
UTM = "EPSG:32618"   # meters, for distances and lengths
AREA = dict(west=-73.9425, south=40.8490, east=-73.9360, north=40.8530)   # around W 181st St
BBOX = (AREA["west"], AREA["south"], AREA["east"], AREA["north"])
plt.rcParams.update({"figure.dpi": 110, "axes.titlesize": 11, "figure.facecolor": "white"})

def read_open_data(url, tries=4):
    # NYC Open Data sometimes answers with a server error; try again before giving up.
    for attempt in range(tries):
        r = requests.get(url, timeout=60)
        if r.ok:
            return gpd.read_file(io.BytesIO(r.content))
        time.sleep(2 ** attempt)
    r.raise_for_status()

def credit(fig):
    fig.subplots_adjust(bottom=0.15)
    fig.text(0.01, 0.01, "Map data © OpenStreetMap contributors (openstreetmap.org/copyright), ODbL. NYC DOT, NYC OTI, NY State GIS, NOAA", fontsize=7, color="#555555")

The published graph¶

The OpenSidewalks Schema describes a pedestrian network as Nodes (points with an _id) and Edges (lines whose _u_id and _v_id name the Nodes they start and end at). A plaza or another area that people cross in any direction is a Pedestrian Zone: a polygon whose _w_id lists the Nodes on its outline, in order. A graph can be built from those fields alone, with no spatial matching. FlatGeobuf supports reading one bounding box over HTTP, so this cell downloads only the features around W 181st Street.

In [2]:
g = gpd.read_file(RELEASE, bbox=BBOX)
edges = g[g.geom_type == "LineString"].copy()
nodes = g[g.geom_type == "Point"].copy()
zones = g[g.geom_type == "Polygon"].copy()
# FlatGeobuf has no list type, so a zone's _w_id is a JSON array in a string.
zones["_w_id"] = [json.loads(w) for w in zones["_w_id"]] if len(zones) else []

def entity(row):
    if row["highway"] == "steps":
        return "Steps"
    if row["highway"] == "pedestrian":
        return "Pedestrian Road"
    if row["highway"] == "footway":
        return {"sidewalk": "Sidewalk", "crossing": "Crossing"}.get(row["footway"], "Footway")
    return "Road"

edges["entity"] = edges.apply(entity, axis=1)
nodes["entity"] = np.where(nodes["barrier"] == "kerb", "Curb Ramp", "Node")
zones["entity"] = "Pedestrian Zone"
pd.concat([edges["entity"], nodes["entity"], zones["entity"]]).value_counts().rename("features in this area").rename_axis(None).to_frame()
Out[2]:
features in this area
Node 654
Sidewalk 582
Footway 502
Road 480
Crossing 196
Curb Ramp 108
Steps 8
Pedestrian Zone 1
In [3]:
COLOURS = {"Sidewalk": "#333333", "Crossing": "#1f6fd1", "Footway": "#8a8a8a", "Steps": "#c0392b", "Pedestrian Road": "#7a5c99", "Road": "#d9d9d9"}
ZONE_FILL = "#e6dcf0"

def base_map(ax, title):
    if len(zones):
        zones.to_crs(UTM).plot(ax=ax, color=ZONE_FILL, edgecolor="#7a5c99", linewidth=0.8)
    for name, colour in COLOURS.items():
        part = edges[edges["entity"] == name].to_crs(UTM)
        if len(part):
            part.plot(ax=ax, color=colour, linewidth=0.8 if name == "Road" else 1.6, label=f"{name} Edge")
    ax.set_title(title); ax.set_axis_off()

fig, ax = plt.subplots(figsize=(9, 7)); credit(fig)
base_map(ax, "OpenSidewalks Edges and Curb Ramp Nodes around W 181st St")
nodes[nodes["entity"] == "Curb Ramp"].to_crs(UTM).plot(ax=ax, color="#3b2f8f", markersize=10, zorder=3, label="Curb Ramp Node")
handles, _ = ax.get_legend_handles_labels()
if len(zones):
    handles.append(Patch(facecolor=ZONE_FILL, edgecolor="#7a5c99", label="Pedestrian Zone"))
ax.legend(handles=handles, loc="lower left", fontsize=8);
No description has been provided for this image

Step 1: OpenStreetMap ways become Edges¶

The pipeline reads one dated OpenStreetMap extract (Geofabrik, 1 October 2026), keeps the ways a pedestrian can use, and gives each one an OpenSidewalks entity type from its tags. This is the function that decides, applied to a few typical tag sets. The pipeline class column is the function's answer, which is the pipeline's own grouping. The published highway column is the highway value the Edge carries in the release, which is what gives it its schema type. An empty row means the way is left out.

In [4]:
examples = pd.DataFrame([
    {"highway": "footway", "footway": "sidewalk"},
    {"highway": "footway", "footway": "crossing"},
    {"highway": "footway", "crossing": "marked"},
    {"highway": "steps"},
    {"highway": "path"},
    {"highway": "pedestrian"},
    {"highway": "cycleway", "foot": "designated"},
    {"highway": "cycleway"},
    {"highway": "residential"},
])

def published_highway(row, kind):
    # The rule in schema_map._osm_edges_to_osw: Steps, Pedestrian Roads and roads keep their OSM value.
    if kind is None:
        return None
    if kind == "street" or (kind == "footway" and row["highway"] in ("steps", "pedestrian")):
        return row["highway"]
    return "footway"

examples["pipeline class"] = [_classify_osm_edge(row) for _, row in examples.iterrows()]
examples["published highway"] = [published_highway(row, kind) for (_, row), kind in zip(examples.iterrows(), examples["pipeline class"])]
examples.fillna("")
Out[4]:
highway footway crossing foot pipeline class published highway
0 footway sidewalk sidewalk footway
1 footway crossing crossing footway
2 footway marked crossing footway
3 steps footway steps
4 path footway footway
5 pedestrian footway pedestrian
6 cycleway designated footway footway
7 cycleway footway footway
8 residential street residential

Steps and Pedestrian Roads are their own Edge types in the OpenSidewalks Schema, identified by highway=steps and highway=pedestrian. The published Edge keeps highway=steps or highway=pedestrian, which is its schema type, so a routing profile can tell them apart. The pipeline's own class puts them with footways only to decide which staging file they go in.

An OSM path has no schema type. It is published as highway=footway and keeps ext:osm_highway=path, so a park trail can be told from a footway.

The table shows linear ways. A closed way tagged area=yes with highway=pedestrian, footway or path is a surface, such as a plaza. From v0.3.6 it is published as a Pedestrian Zone: a Polygon with highway=pedestrian and _w_id, the Nodes on its outline in order. Up to v0.3.4 its outline was published as Edges, so a route went round a plaza and never across it.

OpenSidewalks Edges are directional, because incline only has a meaning in a direction of travel. This dataset stores both directions of every segment, so each OSM way appears as pairs of Edges with opposite signs of incline.

In [5]:
steep = edges[(edges["entity"] == "Sidewalk") & (edges["incline"] > 0.03)].iloc[0]
way = edges[((edges["_u_id"] == steep["_u_id"]) & (edges["_v_id"] == steep["_v_id"])) |
            ((edges["_u_id"] == steep["_v_id"]) & (edges["_v_id"] == steep["_u_id"]))]
way[["_id", "_u_id", "_v_id", "ext:osm_id", "name", "incline"]]
Out[5]:
_id _u_id _v_id ext:osm_id name incline
4 422d59e1f7aa50a4 bbce1a6f90965c6d 706e885109d0b6d4 1038397299 NaN -0.0559
5 1baa9c92bd22d964 706e885109d0b6d4 bbce1a6f90965c6d 1038397299 NaN 0.0559

Step 2: Surveyed curb ramps become Curb Ramp Nodes¶

NYC DOT publishes 217,679 surveyed curb ramps on NYC Open Data (dataset ufzp-rrqu). Cyclomedia collected the survey for DOT from vehicle-mounted street-level imagery and LiDAR, mostly in 2018. Nobody measured the ramps on site for this dataset. The pipeline turns each survey record into a Curb Ramp Node, keeping the survey's slopes and warning surface status, and then moves it onto the nearest Edge endpoint or Pedestrian Zone ring vertex within 5 m. That Node's position is the OpenStreetMap vertex, and its other values are the survey's, so it carries ext:source=nyc_dot_ramps and ext:ramp_id.

The schema puts a curb where a Crossing meets the short Footway that leads to the Sidewalk. NYC's OpenStreetMap data mostly joins Crossings straight to Sidewalks, and this dataset keeps that layout, so many ramps sit on that junction or on a Sidewalk vertex.

The survey records that a ramp was there, not that it is usable today.

In [6]:
url = ("https://data.cityofnewyork.us/resource/ufzp-rrqu.geojson?$limit=5000&$where="
       f"within_box(the_geom,{AREA['north']},{AREA['west']},{AREA['south']},{AREA['east']})")
ramps_raw = read_open_data(url)
print(len(ramps_raw), "survey records in this area")
ramps_raw[["rampid", "stname1", "stname2", "ramp_running_slope_total", "ramp_cross_slope", "dws_conditions"]].head()
101 survey records in this area
Out[6]:
rampid stname1 stname2 ramp_running_slope_total ramp_cross_slope dws_conditions
0 200680 WEST 180 STREET FT WASHINGTON AVENUE 3.1 -1.6 Missing
1 200690 WEST 180 STREET FT WASHINGTON AVENUE 2.9 -1.1 Missing
2 204377 PINEHURST AVENUE WEST 180 STREET 7.3 -3.2 Missing
3 202920 WEST 180 STREET PINEHURST AVENUE 5.6 -2.6 Missing
4 200699 WEST 180 STREET FT WASHINGTON AVENUE 3.1 -0.9 Missing
In [7]:
curbs = _ramps_to_curb_nodes(ramps_raw, "0.3.7+nyc.1", manifest={})
walk_edges = edges[edges["entity"].isin(["Sidewalk", "Crossing", "Footway", "Pedestrian Road", "Steps"])]
# A zone takes part through its ring, as in Stage 4.
walk_ends = _endpoint_coords(pd.concat([walk_edges[["_id", "geometry"]], _ring_rows(zones)[["_id", "geometry"]]]))
snapped = _snap_curb_nodes(curbs, walk_ends, snap_tolerance_m=5.0)
curbs[["_id", "kerb", "tactile_paving", "ext:running_slope_pct", "ext:cross_slope_pct"]].head()
    DOT ramps → CurbRamp nodes: 101
    Building KD-tree over 2,624 edge endpoints...
    Snapped 98/101 curb nodes (tolerance 5.0 m), 0 of them to a crossing end
Out[7]:
_id kerb tactile_paving ext:running_slope_pct ext:cross_slope_pct
0 7ec968c70efc78df lowered no 3.1 -1.6
1 910f8933ea0bc9fd lowered no 2.9 -1.1
2 435cf85bdf417af2 lowered no 7.3 -3.2
3 c7284117e2e95c11 lowered no 5.6 -2.6
4 10ff9d8bd0ae1e85 lowered no 3.1 -0.9
In [8]:
moved = gpd.GeoDataFrame({"before": curbs.geometry.values, "after": snapped.geometry.values}, geometry="before", crs=4326)
moved["d"] = moved.to_crs(UTM).distance(gpd.GeoSeries(moved["after"], crs=4326).to_crs(UTM))

fig, ax = plt.subplots(figsize=(9, 7)); credit(fig)
base_map(ax, "Survey positions (hollow) and the Edge endpoints they snap to (filled)")
gpd.GeoSeries(moved["before"], crs=4326).to_crs(UTM).plot(ax=ax, facecolor="none", edgecolor="#3b2f8f", markersize=18, zorder=3)
gpd.GeoSeries(moved.loc[moved["d"] > 0, "after"], crs=4326).to_crs(UTM).plot(ax=ax, color="#3b2f8f", markersize=8, zorder=4)
print(f"{(moved['d'] > 0).sum()} of {len(moved)} ramps snapped, median move {moved.loc[moved['d'] > 0, 'd'].median():.1f} m")
98 of 101 ramps snapped, median move 1.1 m
No description has been provided for this image

Step 3: Sidewalk polygons give widths¶

NYC's planimetric database (dataset 52n9-sdep) holds sidewalk polygons traced from 2022 aerial imagery. A sidewalk Edge whose centroid falls inside a polygon takes the polygon's mean width, twice its area divided by its perimeter. That width is an estimate for the whole polygon, not the clear width at one spot, and a width tagged in OSM takes precedence.

In [9]:
url = ("https://data.cityofnewyork.us/resource/52n9-sdep.geojson?$limit=5000&$where=" +
       quote(f"intersects(the_geom,'POLYGON(({AREA['west']} {AREA['south']},{AREA['east']} {AREA['south']},{AREA['east']} {AREA['north']},{AREA['west']} {AREA['north']},{AREA['west']} {AREA['south']}))')"))
polygons = read_open_data(url)
sidewalks = edges[edges["entity"] == "Sidewalk"]
recomputed = _join_widths_from_planimetric(sidewalks.drop(columns="width"), polygons)

fig, ax = plt.subplots(figsize=(9, 7)); credit(fig)
polygons.to_crs(UTM).plot(ax=ax, color="#e8e3d3", edgecolor="#b5ad92", linewidth=0.4)
recomputed.to_crs(UTM).plot(ax=ax, column="width", cmap="viridis", linewidth=1.8, legend=True,
                            legend_kwds={"label": "width (m)", "shrink": 0.6})
ax.set_title("Planimetric sidewalk polygons and the width each sidewalk Edge takes from them"); ax.set_axis_off()
    Width assigned to 494/582 OSM sidewalk edges
No description has been provided for this image
In [10]:
compare = pd.DataFrame({"recomputed here": recomputed["width"], "in the release": sidewalks["width"]}).dropna()
print(f"{len(compare)} sidewalk Edges; recomputed and published widths agree on "
      f"{np.isclose(compare.iloc[:, 0], compare.iloc[:, 1], atol=0.01).mean():.0%} of them")
compare.describe().round(2)
494 sidewalk Edges; recomputed and published widths agree on 100% of them
Out[10]:
recomputed here in the release
count 494.00 494.00
mean 4.15 4.15
std 0.60 0.60
min 3.12 3.12
25% 3.63 3.63
50% 4.22 4.22
75% 4.50 4.50
max 5.39 5.39

Step 4: The LiDAR terrain model gives elevation and incline¶

The city's 2017 LiDAR survey has a bare-earth terrain model, served by NY State GIS. The pipeline downloads it in 2 m tiles, reads a height at each Node, and sets each Edge's incline, an estimate, to the rise over the run in its direction of travel. Before taking the difference it averages each Node's height with its neighbours' over Edges shorter than 5 m, so that survey noise on short Edges does not read as a slope. The recomputation below skips that averaging, so short Edges differ from the release.

A Node has no ext:elevation_m where the terrain model has no data (open water, mostly) or where every Edge at it is a tunnel. Where the heights at an Edge's two ends cannot be a slope (50% or steeper on an Edge that is not Steps, over 100% on Steps), the Edge has no incline and carries ext:incline_unknown=yes.

In [11]:
dem_path = Path(tempfile.mkdtemp()) / "dem.tif"
_fetch_dem_tile(AREA, "notebook", dem_path, resolution_m=2.0)
dem = rasterio.open(dem_path)

def heights(points):
    return np.array([v[0] for v in dem.sample([(p.x, p.y) for p in points])])

ped = edges[edges["entity"] != "Road"].copy()
starts = heights(ped.geometry.apply(lambda l: gpd.points_from_xy([l.coords[0][0]], [l.coords[0][1]])[0]))
ends = heights(ped.geometry.apply(lambda l: gpd.points_from_xy([l.coords[-1][0]], [l.coords[-1][1]])[0]))
ped["length_m"] = ped.to_crs(UTM).length
ped["incline_here"] = (ends - starts) / ped["length_m"]

fig, axes = plt.subplots(1, 2, figsize=(12, 5.5)); credit(fig)
z = dem.read(1)
axes[0].imshow(z, cmap="terrain", extent=(dem.bounds.left, dem.bounds.right, dem.bounds.bottom, dem.bounds.top))
axes[0].set_title(f"Terrain model, {int(round(z.min()))} m to {int(round(z.max()))} m"); axes[0].set_axis_off()
long = ped[ped["length_m"] >= 5]
axes[1].scatter(long["incline"] * 100, long["incline_here"] * 100, s=4, alpha=0.5)
axes[1].plot([-20, 20], [-20, 20], color="grey", linewidth=0.8)
axes[1].set(xlim=(-20, 20), ylim=(-20, 20), xlabel="incline in the release (%)", ylabel="recomputed here (%)",
            title="Edges 5 m or longer");
  DEM [notebook]: 273×222 px (~2.0m res)...
No description has been provided for this image
In [12]:
fig, ax = plt.subplots(figsize=(9, 7)); credit(fig)
edges[edges["entity"] == "Road"].to_crs(UTM).plot(ax=ax, color="#dddddd", linewidth=0.8)
steep = np.select([ped["incline"].abs() <= 0.05, ped["incline"].abs() <= 0.083], ["#2b7a3d", "#d69a00"], "#c0392b")
ped.to_crs(UTM).plot(ax=ax, color=steep, linewidth=1.6)
ax.set_title("Pedestrian Edges by incline: green up to 5%, amber to 8.3%, red steeper"); ax.set_axis_off()
No description has been provided for this image

Step 5: On bridges, the point clouds give the deck¶

A bare-earth model describes what is under a bridge: the ground, or the water. For Nodes on bridges and elevated ways the pipeline reads the classified LiDAR point clouds (the 2017 city survey and, where that has no returns over open water, the 2014 USGS survey) and follows the walking surface from the ground at each end. Those Nodes carry ext:elevation_source. Reading the point clouds takes several minutes, so this cell compares the published deck heights on the Brooklyn Bridge with the terrain model under them.

In [13]:
BRIDGE = dict(west=-74.0010, south=40.7010, east=-73.9890, north=40.7115)
b = gpd.read_file(RELEASE, bbox=(BRIDGE["west"], BRIDGE["south"], BRIDGE["east"], BRIDGE["north"]))
promenade = b[(b.geom_type == "LineString") & b["name"].fillna("").str.contains("Brooklyn Bridge")]
ids = set(promenade["_u_id"]) | set(promenade["_v_id"])
deck = b[(b.geom_type == "Point") & b["_id"].isin(ids) & b["ext:elevation_source"].notna()].copy()

dem_path = Path(tempfile.mkdtemp()) / "bridge.tif"
_fetch_dem_tile(BRIDGE, "bridge", dem_path, resolution_m=2.0)
dem = rasterio.open(dem_path)
deck["ground"] = heights(deck.geometry)
manhattan_end = gpd.GeoSeries(gpd.points_from_xy([-74.0045], [40.7123]), crs=4326).to_crs(UTM).iloc[0]
deck["distance"] = deck.to_crs(UTM).distance(manhattan_end)
deck = deck.sort_values("distance")

fig, ax = plt.subplots(figsize=(10, 4)); credit(fig)
ax.plot(deck["distance"], deck["ground"], ".", color="#8a6d3b", label="terrain model (ground or water)")
ax.plot(deck["distance"], deck["ext:elevation_m"], ".", color="#1f6fd1", label="published Node height (deck)")
ax.set(xlabel="distance from the Manhattan end (m)", ylabel="height (m, NAVD88)",
       title="Brooklyn Bridge promenade: deck heights against the ground below")
ax.legend(); deck["ext:elevation_source"].value_counts().rename("Nodes by height source").rename_axis(None).to_frame()
  DEM [bridge]: 506×584 px (~2.0m res)...
Out[13]:
Nodes by height source
lidar_2017 33
lidar_2014 10
No description has been provided for this image

Step 6: Assembly into a routable graph¶

Because every Edge names its Nodes, the graph needs no spatial matching. A Pedestrian Zone names the Nodes on its outline, and the routing layer turns it into edges a person can walk: the outline itself, and a straight line between every two of its entrances that stays inside the polygon. Two rules then make the wheelchair profile:

  • A Crossing counts as ramped when a surveyed ramp lies within 5 m of each of its ends (crossings_with_ramps).
  • The cost function refuses Steps, motor vehicle road Edges, Crossings without ramps, inclines over 8.3% up or 10% down, and Edges marked ext:incline_unknown (unweaver-project/cost-wheelchair.py).

The 5 m rule is this project's own. The cost function is adapted from the example wheelchair profile of Unweaver by Nick Bolten (Apache License 2.0), the routing engine behind AccessMap. This project added the refusals for Steps, motor vehicle roads and Edges marked ext:incline_unknown. An Edge with no incline and no mark, such as a tunnel, was not measured and still passes. Following the OpenSidewalks approach, the data stores measured and estimated values, and a routing application chooses the rules.

In [14]:
node_xy = {r["_id"]: (r.geometry.x, r.geometry.y) for _, r in nodes.iterrows()}
node_z = {r["_id"]: float(r["ext:elevation_m"]) for _, r in nodes.iterrows() if pd.notna(r["ext:elevation_m"])}
endpoints = set(edges["_u_id"]) | set(edges["_v_id"])

# Each Pedestrian Zone as the edges a person can walk across it (pipeline.utils.zones.zone_edges).
zone_walk = []
for _, z in zones.iterrows():
    node_xy.update(zip(z["_w_id"], [c[:2] for c in z.geometry.exterior.coords[:-1]]))   # ring Nodes outside the box
    zone_walk += zone_edges({"properties": {"_id": z["_id"], "_w_id": z["_w_id"]}}, node_xy, endpoints, node_z)
ring_nodes = {n for z in zones["_w_id"] for n in z}

features = json.loads(g.to_json(default=str))["features"]
features += [{"type": "Feature", "properties": {k: v for k, v in e.items() if k != "coordinates"},
              "geometry": {"type": "LineString", "coordinates": e["coordinates"]}} for e in zone_walk]
curb_ids = [n for n in nodes.loc[nodes["entity"] == "Curb Ramp", "_id"] if n in endpoints | ring_nodes]
ramped = osw_to_unweaver.crossings_with_ramps(features, curb_ids, node_xy, reach_m=5.0)

G = nx.DiGraph()
def add(u, v, d):
    if not G.has_edge(u, v) or G[u][v]["length"] > d["length"]:
        G.add_edge(u, v, **d)

edges["length"] = edges.to_crs(UTM).length
for _, e in edges.iterrows():
    add(e["_u_id"], e["_v_id"],
        {"subclass": {"Steps": "steps", "Road": "street"}.get(e["entity"], e["footway"] or "footway"),
         "footway": e["footway"], "curbramps": e["_id"] in ramped,
         "incline": None if pd.isna(e["incline"]) else e["incline"],
         "incline_unknown": e.get("ext:incline_unknown") == "yes", "length": e["length"], "geometry": e.geometry})
for e in zone_walk:
    add(e["_u_id"], e["_v_id"],
        {"subclass": "footway", "footway": None, "curbramps": False, "incline": e["incline"], "incline_unknown": bool(e.get("ext:incline_unknown")),
         "length": e["length_m"], "geometry": LineString(e["coordinates"])})

wheelchair = cost_wheelchair.cost_fun_generator(G)
def walking(u, v, d):
    return None if d["subclass"] == "street" else d["length"]
print(f"{G.number_of_nodes():,} Nodes, {G.number_of_edges():,} directed edges, {len(zone_walk)} of them across {len(zones)} Pedestrian Zone{'' if len(zones) == 1 else 's'}, "
      f"{len(ramped)} Crossing Edges ramped at both ends")
[crossings] 196 edges in 48 crossings; 196 edges on a crossing with a ramp at each end
809 Nodes, 1,818 directed edges, 50 of them across 1 Pedestrian Zone, 196 Crossing Edges ramped at both ends

A trip across the hill, from Fort Washington Avenue to Wadsworth Avenue. The walking profile takes the stairs. The wheelchair profile goes round.

In [15]:
ORIGIN, DESTINATION = (-73.9407, 40.8522), (-73.9379, 40.8522)

def nearest(lon, lat):
    pts = nodes[nodes["_id"].isin(G.nodes)].to_crs(UTM)
    here = gpd.GeoSeries(gpd.points_from_xy([lon], [lat]), crs=4326).to_crs(UTM).iloc[0]
    return pts.loc[pts.distance(here).idxmin(), "_id"]

origin, destination = nearest(*ORIGIN), nearest(*DESTINATION)
routes = {}
for name, weight in [("walking", walking), ("wheelchair", wheelchair)]:
    try:
        path = nx.shortest_path(G, origin, destination, weight=weight)
        routes[name] = gpd.GeoSeries([G[a][b]["geometry"] for a, b in zip(path, path[1:])], crs=4326)
    except nx.NetworkXNoPath:
        routes[name] = None

fig, ax = plt.subplots(figsize=(9, 7)); credit(fig)
base_map(ax, "One trip under each profile")
for name, colour, style in [("walking", "black", ":"), ("wheelchair", "#1f6fd1", "-")]:
    if routes[name] is not None:
        routes[name].to_crs(UTM).plot(ax=ax, color=colour, linewidth=3.5, linestyle=style)
        print(f"{name}: {routes[name].to_crs(UTM).length.sum():.0f} m")
    else:
        print(f"{name}: no route")
ends = gpd.GeoSeries(gpd.points_from_xy([ORIGIN[0], DESTINATION[0]], [ORIGIN[1], DESTINATION[1]]), crs=4326).to_crs(UTM)
ax.scatter(ends.x, ends.y, s=120, color="white", edgecolor="black", zorder=5)
for label, p in zip("AB", ends):
    ax.annotate(label, (p.x, p.y), ha="center", va="center", fontsize=8, weight="bold", zorder=6)
walking: 538 m
wheelchair: 815 m
No description has been provided for this image

Step 7: Validation¶

The release is checked with the schema validator that the Taskar Center for Accessible Technology publishes, python-osw-validation 0.5.0, which returns zero errors over all 3,995,589 features of v0.3.7. The validator checks form, not whether the data matches the street. The checks it does not make (elevation, deck heights, connectivity by borough, ramps against the survey, widths) are in validators/post_build_checks.py, with results in validators/QUALITY_REPORT.md.

What this notebook does not show¶

  • This project checked nothing here on the ground. A surveyed ramp from 2018 may since have been rebuilt, blocked or removed.
  • Incline comes from an airborne survey. A curb ramp a meter long is below what it can resolve.
  • Where OpenStreetMap has no separately mapped sidewalk, the dataset has none.
  • A Pedestrian Zone has no detail inside it. The straight lines the routing layer draws across a plaza do not know about planters, fountains or steps.
  • The 5 m ramp rule was checked on 200 crossings over aerial imagery by language-model raters (Anthropic Claude models, exact versions not recorded), not by people. All 200 rating sheets are in evaluation-sheets.zip on the release.

The full list, with the evidence for each claim, is in evaluation/README.md.

Disclaimer¶

OpenSidewalks NYC is an experimental dataset. This project has not checked any of it on the ground. Do not use it to tell anyone that a route is accessible, and do not rely on it, or on an app built from it, to plan a trip.

The curb ramp survey shows that a ramp was there when it was captured, mostly in 2018, not that it is usable today. Incline and width are estimates, not measurements.

OpenSidewalks NYC is an independent project by Adam Munawar Rahman. It is not made or endorsed by the Taskar Center for Accessible Technology, OpenSidewalks or the Transportation Data Equity Initiative (TDEI), nor by NYC DOT or the City of New York. No wheelchair user or disability organization has reviewed the data or the wheelchair profile.

Language models drafted most of the code and documentation and did the imagery ratings. How This Was Made says how they were used.