Automatically detect curb ramps in streetscape imagery across large geographic areas and submit the predictions to Project Sidewalk.
Give it a geographic area (a GeoJSON polygon), and it will:
.jsonl file.This repo is the deployment / inference tool. The actual machine-learning model lives elsewhere:
projectsidewalk/rampnet-model —
the trained model, published on HuggingFace. This repo downloads and runs it; you do not
need the RampNet training code to use this tool.In short: RampNet builds the model; this repo applies it at city scale and feeds the results into Project Sidewalk.
The model takes a 2048×4096 RGB panorama and outputs a heatmap of curb-ramp likelihood.
Local-maximum peak detection turns the heatmap into a list of discrete (x, y, confidence)
detections, normalized to [0, 1], stored down to a 0.10 floor and submitted at the 0.30
operating point (detectors/__init__.py).
GeoJSON area
│
▼
[1] Find panoramas main.py → scan map tiles (zoom 17) for GSV pano IDs
│ inside the polygon
▼
[2] Download + detect panorama.py → reassemble each panorama from GSV tiles
curb_ramp.py → run RampNet, extract peak detections
│
▼
[3] Write predictions <area>.jsonl → one JSON line per processed panorama
│
▼
[4] Submit (optional) send_to_ps.py → POST each record to Project Sidewalk
| File | Role |
|---|---|
main.py | Entry point for stages 1–3. Finds panos, runs detection, writes JSONL. |
panorama.py | Reassembles a full equirectangular panorama from individual GSV tiles. |
detectors/curb_ramp.py | Wraps the projectsidewalk/rampnet-model HuggingFace model. |
send_to_ps.py | Stage 4. Reads a JSONL file and submits predictions to Project Sidewalk. |
example_geojson/ | Example area polygons (bend.geojson, chicago.geojson, vancouver.geojson). |
Read this before you start. This repo is only the last link in a longer chain. It generates curb-ramp predictions and submits them to a Project Sidewalk server — but it cannot create the city on that server. The submission endpoint (
/ai/submitLabelsOnPano) only accepts labels for a city that already exists in the database, with its regions and street network in place.
So deploying Bend (or any city) is really two separate projects in two separate repos:
| Where | What | Repo | |
|---|---|---|---|
| A. Stand up the city (prerequisite) | Server-side | Create the DB schema, import neighborhood/region boundaries and the OSM street network, configure the city, deploy the server | SidewalkWebpage |
| B. Generate + submit AI labels (this repo) | Your machine | Detect curb ramps across GSV imagery and POST them to that server | this repo |
This is the larger, multi-step effort — mostly QGIS data prep — documented on the PS wiki. Roughly:
make create-new-schema name=sidewalk_bend_or.qgis_region table (with region_id).qgis_road table (with road_id).make fill-new-schema builds street_edge, region,
street_edge_region, region_completion, etc.config table — city_center_lat/lng, SW/NE map boundaries,
open_status, zoom — plus cityparams.conf, UI translations, Google Analytics ID, and
whitelisting the server URL on the Google Maps/Street View API key.check_streets_for_imagery.py then
make hide-streets-without-imagery.pg_dump the schema and provision server infrastructure with IT.Reference wiki pages:
The PS wiki estimates this stage takes significant effort (but we are trying to streamline it), dominated by QGIS data cleaning. If a Bend instance already exists, skip all of this and just point
ENDPOINT_URL(insend_to_ps.py) at it.
The polygon you feed main.py defines where panoramas are searched for. For the
submitted labels to land on real streets, that search area should describe the same Bend
as the regions/street network in the Project Sidewalk database. Mismatched boundaries mean
detections with nowhere valid to attach.
A CUDA-capable GPU is strongly recommended — running the detector on CPU is very slow.
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
On linux-64 that pulls a CUDA-enabled PyTorch wheel by default, so the GPU is used with no extra step. On Windows/macOS the default wheel is CPU-only — install the CUDA build first:
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu126
pip install -r requirements.txt
conda works too (
conda env create -f environment.yml && conda activate sidewalk-auto-labeler), but it buys nothing here: no dependency comes from a conda channel, and the CUDA runtime already arrives as thenvidia-*-cu12pip wheels torch depends on.environment.ymlpins only the interpreter and defers torequirements.txt, which is the single source of truth for versions — pin things there, and don'tconda env exportover it.
The environment pins a CUDA 12.6 build of PyTorch. On first run, the RampNet model (~hundreds of MB) is downloaded from HuggingFace and cached locally.
python main.py example_geojson/bend.geojson --name bend --scan-only
This scans coverage tiles only (no model load, no processing) and reports how many
panoramas are in the polygon plus a rough runtime estimate — so you know whether you're
committing to minutes or days before launching. On a resumed run it also tells you how
many panos remain. The scan is saved to runs/<name>/scan.json, so the real run can skip
the tile pass with --reuse-scan. That flag is off by default on purpose: coverage
changes over time, and a reused scan misses any panoramas published since it ran (the
manifest records the scan's age). Use it for a scan-then-run pair, not for a resume weeks
later.
python main.py example_geojson/bend.geojson --name bend
This writes all per-area state to runs/bend/ (--name defaults to the GeoJSON filename
stem):
runs/bend/
results.jsonl # one prediction record per processed panorama
already_processed.txt # resume cache
manifest.json # geometry hash, model provenance, per-run stats
area.geojson # exact copy of the geometry used
scan.json # last coverage scan's pano list (for --reuse-scan)
spot_check/ # optional visual-QA gallery (see below)
The run:
detections list). This is intentional: it
records that the pano was inspected.The run is resumable and cached (see below), so it's safe to stop and restart.
Both steps accept --source mapillary to run on Mapillary
360° imagery (CC BY-SA) instead of GSV — e.g. for cities where Project Sidewalk deploys
on Mapillary coverage:
# Client token from mapillary.com/dashboard/developers — export it, or put
# MAPILLARY_ACCESS_TOKEN=MLY|... in a ./.env file (gitignored, loaded automatically).
export MAPILLARY_ACCESS_TOKEN="MLY|..."
python main.py example_geojson/richmond.geojson --name richmond --source mapillary --scan-only
python main.py example_geojson/richmond.geojson --name richmond --source mapillary
Coverage comes from Mapillary's z14 vector tiles (only 360° panoramas are kept —
is_pano), imagery from the Graph API's full-resolution thumb_original_url. Because
contributors re-drive the same streets, coverage is spatially thinned to the best pano
per grid cell (newest capture, quality-score tiebreak; --thin-spacing N meters,
default 5 m, 0 disables) before processing. A run
directory is bound to one source the same way it's bound to one geometry; use a
different --name per source.
Pano positions are checked on every run, and the submitter refuses a failed check.
Mapillary serves two positions per
image: the camera's GPS fix (geometry) and an SfM-corrected one (computed_geometry).
The run submits one of them (--mapillary-position sfm|raw, default sfm, recorded in
the manifest). SfM is usually the better position, but its alignment to GPS is one
transform per reconstruction, so a whole sequence can sit several metres off the street as
a block — in Laurens, IA every intersection's labels landed 8–10 m west
(SidewalkWebpage#5361) —
and every label placed from a pano inherits that pano's error one-to-one. Which field is
right is a per-city, per-sequence question, so it is measured rather than assumed: main.py
runs the check at the end of every run (--no-position-check skips it on a host without
internet egress), and send_to_ps.py refuses a Mapillary file whose check is missing, was
made before the file last changed, or is flagged (--ignore-position-check overrides, for a
case you have looked at by hand). To re-run it, or to confirm a repositioned file:
python scripts/position_check.py runs/richmond --report
This scores every pano against OpenStreetMap street centerlines (one Overpass query,
cached beside the run; no imagery, no GPU, no second source needed), reports each
field's offset distribution and per-sequence bias, writes position_check.json and a
self-contained position_report.html (interactive map, offset histograms, per-sequence
table — both are git-tracked beside manifest.json), and exits non-zero when a sequence
is off the street on the submitted field and switching to the other field would move it
at least 2 m closer (a swap forces a new submission campaign, so it has to buy
something; sequences that sit off the street in both fields — wide one-way streets
driven once — are reported separately). A flagged run is repaired without re-detecting:
python scripts/reposition.py runs/richmond/results.jsonl --from-check # flagged sequences only
python scripts/reposition.py runs/richmond/results.jsonl --field raw # whole file
python scripts/position_check.py runs/richmond --results runs/richmond/results.check.jsonl # confirm
which writes a new results file with the pano positions rewritten from the other field
(the detections are stored relative to the pano, so nothing else changes; the heading is
SfM-derived too, but the measured discrepancies are translations, so it stays). The new
file has a new hash, so send_to_ps.py treats it as a fresh campaign — the labels already
on the server from the old positions have to be retired there first. Check and submit it
where it is: it is a submission artifact, not a run, and swapping it into results.jsonl
would let a later resume append panos on the manifest's field to a mixed file.
--source panoramax runs on Panoramax, the federated open
street-level imagery commons started by IGN and OpenStreetMap France (CC BY-SA 4.0 on most
instances, Etalab 2.0 on IGN's). No token is needed:
python main.py example_geojson/bayonne.geojson --name bayonne --source panoramax --scan-only
python main.py example_geojson/bayonne.geojson --name bayonne --source panoramax
Coverage comes from the federation catalog's z15 vector tiles (only equirectangular
pictures are kept), imagery from each picture's hd asset on its home instance — the
original upload, unsigned. Thinning works as for Mapillary (newest capture per cell,
pixel-density tiebreak). PANORAMAX_API_URL points a run at a single instance (e.g. a
self-hosted one) instead of the federation; it's checked against the instance's STAC
landing page at startup, so a mistyped root fails before the model loads rather than
producing a run that scans every tile and finds nothing. Coverage is overwhelmingly French today; the
Bayonne and Lyon boundaries in example_geojson/ are two communes with dense, recent 360°
coverage.
Ground-truth review and precision/recall scoring live in RampNet,
not in this repo. The GT gallery (scripts/gt_gallery.py) and scorer (rampnet.validation +
scripts/score_validation.py) run there on a self-contained per-city benchmark bundle under
RampNet's benchmark/<city>/. Because only this repo can fetch imagery, its one job in that
flow is to export the bundle — native-resolution panos plus the detection records:
python scripts/export_benchmark.py runs/bend/results.jsonl --out <bundle>/panos
Hand that bundle to RampNet; do the gallery review and scoring there. (This is production
tooling only — the validation half was migrated out; see CLAUDE.md.)
# Preview the transformed payloads without sending anything:
python send_to_ps.py runs/bend/results.jsonl --dry-run
# Send a handful first and look at them in the Project Sidewalk interface:
python send_to_ps.py runs/bend/results.jsonl --endpoint https://your-ps-server/ai/submitLabelsOnPano --limit 3
# Then the rest (the capped run above is already recorded, so this picks up where it left off):
python send_to_ps.py runs/bend/results.jsonl --endpoint https://your-ps-server/ai/submitLabelsOnPano
This reads each JSONL line and POSTs it to the Project Sidewalk endpoint. It also converts
the normalized detection coordinates from step 1 into pixel coordinates
(pano_x, pano_y) using the panorama dimensions stored in each record.
PS_INTERNAL_API_KEY — either inline
(PS_INTERNAL_API_KEY=… python send_to_ps.py …) or in a gitignored .env (copy
.env.example) — and it is sent as an Authorization: Bearer header. Point
--api-key-env at a different variable to keep several instances' keys side by side.
If the variable is unset, no auth header is sent and the server answers 401.https://. With a key set, send_to_ps.py refuses a
cleartext remote --endpoint before sending anything, so a mistyped URL can't leak the
key to every hop in between. http://localhost:… stays allowed — loopback (and a local
server's 0.0.0.0 bind address) never reaches the wire.--limit N stops after N records. Already-submitted lines don't count
against it, so repeated capped runs walk the file. Use it to verify a new city end to end
— placement, pano rendering, street snapping — before committing thousands of labels.<file>.submitted
sidecar, so re-running skips them instead of re-POSTing. A git-tracked
<file>.submission.json records, per endpoint, what those lines were (sha256 and byte
length of the JSONL) and how many labels went where; the script refuses to run when the two
disagree — an edited file, a lost sidecar, or a sidecar whose lines went to a different
server — so a whole city can't be duplicated, or skipped, silently. Moving from a test
instance to production means moving the sidecar aside, not deleting it.main.py --gap-fill-only appends to results.jsonl, so
"submit, gap-fill, submit the rest" is an ordinary resume: the recorded lines keep their
numbers. The run proves it by re-hashing the recorded byte prefix and checking that no
appended pano id was already submitted, reports how many new lines it found, and re-records
the new hash and length. A file that shrank, whose already-submitted lines changed, or that
re-appends panos already sent (a doubled file, or a re-run after already_processed.txt was
lost) still refuses.The detection coordinates are stored normalized (
0–1) in the JSONL and only converted to pixels at submission time. If you change the coordinate handling on one side, update the other.
You already have a Bend, Oregon polygon at example_geojson/bend.geojson. To run a new city
from scratch:
Polygon or MultiPolygon, either
bare or wrapped in a Feature/FeatureCollection (as geojson.io
exports it). main.py extracts the geometry before hashing, so wrapping does not change
which run a file belongs to, and a collection of several features is dissolved into one
MultiPolygon. A quick source for city limits is
OSM Nominatim: search the city with
polygon_geojson=1, take the boundary=administrative result, and save its geojson
field to a file. Ideally, though, derive the boundary from the
target Project Sidewalk instance's own regions so the two areas match exactly (see
Keep the two areas aligned).example_geojson/ (or anywhere) and run
python main.py path/to/city.geojson --name <city>.python scripts/export_benchmark.py runs/<city>/results.jsonl --out <bundle>/panos) and
review/score it in RampNet.send_to_ps.py at runs/<city>/results.jsonl and the target Project Sidewalk
server, then submit.--scan-only gives the pano count and a runtime estimate in a
minute or two. Throughput is GPU-bound (~1–2 s/pano; inference is serialized), so a
large city is a multi-day single-GPU run regardless of concurrency settings.main.py are the main performance knobs:
COVERAGE_API_CONCURRENCY = 100 — parallel workers scanning map tiles for pano IDs.PROCESSING_CONCURRENCY = 50 — parallel workers downloading + running detection.nohup (e.g. nohup python main.py ... &);
nohup.out is git-ignored.The per-city checklist for expanding beyond the example cities (and open questions like threshold policy and boundary source of truth) is tracked in issue #13.
Two host-specific runbooks live in docs/:
production-deployment.md — the full multi-host
production path: the Slurm cluster setup where the city runs are detected, why the
native-res imagery is archived on a separate file server, measured throughput, and the
operational gotchas. Note that nothing here deploys automatically — no webhooks, no
CI/CD, no cron; every stage is launched by hand.makeability-quickstart.md — the RA-facing happy path
for a single-box run on the lab's makelab2 GPU server.Only detection (main.py without --scan-only) needs the GPU box. Everything else
runs on a laptop (macOS/Windows/Linux) with just internet access — install via
requirements.txt (the plain CPU pip install torch torchvision is fine since the model
never runs; or skip torch entirely and install requirements-test.txt, which covers every
non-detection tool):
| Works on a laptop | Needs |
|---|---|
main.py --scan-only (scope a new city) | network only |
scripts/export_benchmark.py (native-res bundle for RampNet GT) | network + the run's results.jsonl |
send_to_ps.py | network + results.jsonl |
pytest | nothing else |
The catch: results.jsonl is git-ignored (it's tens of MB per city) and lives only on
the machine that ran the detection — copy it over first (e.g.
scp makelab2:.../runs/bend/results.jsonl runs/bend/). The small irreplaceable files —
manifest.json, area.geojson, and hand-labeled *_verdicts.json — are tracked in git
and sync normally.
Each run directory is bound to the SHA-256 hash of its GeoJSON geometry, recorded in
runs/<name>/manifest.json. Progress is tracked in runs/<name>/already_processed.txt.
--name, or restore the
geometry from the run's area.geojson. To fully re-run an unchanged area, delete its
runs/<name>/ directory.manifest.json also records model provenance (model_repo, model_revision, model_id,
training date, api_version — at top level and in each run entry), the streetlevel
version, and per-run counts (found/processed/skipped/failed) — so a months-old results file
is self-describing.--name), so one results.jsonl never
mixes two checkpoints. Manifests from before revisions were recorded resume with a one-time
note and are bound on that resume.manifest.json,
area.geojson, and *_verdicts.json (hand-labeled ground truth) — while results.jsonl
and the resume cache stay local. Archive full-city results.jsonl files as release assets
rather than committing them.Each line of results.jsonl is a JSON object like:
{
"detections": [
{ "x_normalized": 0.61, "y_normalized": 0.78, "confidence": 0.91 }
],
"label_type": "CurbRamp",
"model_id": "rampnet-model@606a11956743",
"model_training_date": "08-21-2025",
"api_version": "1.0.0",
"model_repo": "projectsidewalk/rampnet-model",
"model_revision": "606a11956743f7eb328d9207769034752f6191f4",
"pano": {
"panorama_id": "…",
"capture_date": "2021-06",
"width": 16384,
"height": 8192,
"tile_width": 512,
"tile_height": 512,
"lat": 44.05,
"lng": -121.31,
"camera_heading": 123.4,
"camera_pitch": 1.2,
"camera_roll": 0.3,
"copyright": "© Google",
"source": "launch",
"history": [ { "pano_id": "…", "date": "2019-08" } ],
"links": [ { "target_gsv_panorama_id": "…", "yaw_deg": 90.0, "description": "…" } ],
"camera_make": null,
"camera_model": null,
"camera_type": "equirectangular",
"source_detail": "launch",
"uploader": "Google",
"source_metadata": { "elevation": 1103.5, "country_code": "US", "…": "…" },
"camera_height_m": 2.21,
"camera_height_status": "measured",
"…": "…"
}
}
Notes:
detections is empty ([]) when the panorama was processed but no curb ramps were found.
Camera heading / pitch / roll are written in degrees (converted from the
radians returned by the metadata source).
copyright is an attribution ingredient, not a finished attribution. On Mapillary and
Panoramax it holds the contributor's bare name (the creator's username, or the
picture's producer), null when the source names nobody, and the licence travels beside
it in license — Project Sidewalk composes the ©, the provider and the licence itself
when it shows its own copy of the imagery. The "© Google" above is a GSV record, where
the string is Google's own and is shown verbatim.
Every source writes the same provenance keys: camera_make, camera_model,
camera_type and a source_metadata object. Mapillary and Panoramax fill make/model
from the camera; GSV exposes none, so they are null there, and its analogue is
source_detail (streetlevel's raw source: launch/scout for Google's own capture,
photos:… for a user upload) plus uploader. GSV's source_metadata is an explicit
projection of the streetlevel metadata — uploader, uploader_icon_url, upload_date,
elevation (m), country_code, street_names, address, building_level,
building_levels, places, artworks, neighbors (ids only) — every key present,
null when Google did not send it ([] for building_levels/neighbors, which
streetlevel defaults to an empty list), angles in degrees. GSV records written before
issue #23 have none of these keys.
source is coerced to Project Sidewalk's pano_source enum on submission (launch →
gsv). Project Sidewalk stores the pano's source_metadata verbatim, capped at 64 KB, so
a GSV pano submits only its imagery provenance there — uploader, uploader_icon_url,
upload_date, elevation, country_code — plus the raw source string as
source_detail (filled from source for older records, which submit the same keys with
null values). The bulky context (street names, address, building levels, places,
artworks, neighbors) stays in results.jsonl. Mapillary and Panoramax source_metadata
is submitted unchanged. send_to_ps.py refuses, before the POST, any record whose
submitted source_metadata is over 64 KB.
Model provenance is resolved from the weights actually loaded, never declared (#39):
| Field | Where it comes from | Stored by PS |
|---|---|---|
model_revision | The Hugging Face commit SHA of the loaded snapshot (config._commit_hash, else the hub cache's snapshots/<sha> directory — works offline) | no (passed through, ignored) |
model_repo | detectors.MODEL_REPO | no (passed through, ignored) |
model_id | rampnet-model@<first 12 hex of model_revision> — the old id as a prefix, so string matches on rampnet-model keep working, and a different checkpoint is a different id | label_ai_info.model_id (TEXT) |
model_training_date | Looked up by SHA in detectors.KNOWN_REVISIONS; MM-DD-YYYY because that is what PS parses | label_ai_info.model_training_date |
api_version | detectors.API_VERSION, the version of this record format | label_ai_info.api_version |
A revision not in KNOWN_REVISIONS refuses to start and prints the SHA to add (see the
comment on the table for how). --allow-unknown-model-revision runs it anyway with
model_training_date: null, recorded in the manifest — and send_to_ps.py refuses such a
file, since PS requires the date. Adding the SHA to the table afterwards does not rescue
that run: it refuses to resume (its undated lines would stay undated), so re-run the area
under a fresh --name. Lines written before #39 say "model_id": "rampnet-model" with no model_repo/model_revision; they came from the same paper weights
and submit unchanged.
A small pytest suite covers the parts whose output Project Sidewalk consumes: the JSONL
record format and the normalized→pixel transform in send_to_ps.py. No test touches the
network or loads the model. (Validation-scoring and gallery tests moved to RampNet with
their tooling.)
pip install -r requirements-test.txt # light deps only, no torch needed
pytest
CI runs the same suite on every push (.github/workflows/tests.yml).
.
├── main.py # Stage 1–3: find panos, detect, write JSONL
├── panorama.py # GSV panorama download (via streetlevel)
├── detectors/
│ └── curb_ramp.py # RampNet model wrapper
├── send_to_ps.py # Stage 4: submit predictions to Project Sidewalk (refuses a failed position check)
├── position_check.py # Pano positions vs OSM centerlines; run by main.py at the end of every run
├── position_report_template.html
├── scripts/
│ ├── export_benchmark.py # Native-res imagery bundle for RampNet GT/benchmark
│ ├── position_check.py # Shim: `python scripts/position_check.py runs/<city> --report` re-runs the check
│ ├── reposition.py # Switch a Mapillary run's pano positions between GPS and SfM, no re-detect
│ └── visual_check.py # Single-pano coordinate spot check
├── tests/ # Pytest suite (light deps only; no network, no model)
├── example_geojson/ # Example area polygons (Bend, Chicago, Vancouver)
├── environment.yml # Conda env: pins python only, defers to requirements.txt
├── requirements.txt # Pip requirements — the single source of truth for pins
├── requirements-test.txt # Test/laptop deps (everything except torch)
└── runs/ # Per-area results + resume state (git-ignored)
The code in this repository is released under the MIT License. The RampNet model it downloads from HuggingFace and the imagery it processes carry their own terms.
Automatically detect curb ramps in streetscape imagery across large geographic areas and submit the predictions to Project Sidewalk.
Give it a geographic area (a GeoJSON polygon), and it will:
.jsonl file.This repo is the deployment / inference tool. The actual machine-learning model lives elsewhere:
projectsidewalk/rampnet-model —
the trained model, published on HuggingFace. This repo downloads and runs it; you do not
need the RampNet training code to use this tool.In short: RampNet builds the model; this repo applies it at city scale and feeds the results into Project Sidewalk.
The model takes a 2048×4096 RGB panorama and outputs a heatmap of curb-ramp likelihood.
Local-maximum peak detection turns the heatmap into a list of discrete (x, y, confidence)
detections, normalized to [0, 1], stored down to a 0.10 floor and submitted at the 0.30
operating point (detectors/__init__.py).
GeoJSON area
│
▼
[1] Find panoramas main.py → scan map tiles (zoom 17) for GSV pano IDs
│ inside the polygon
▼
[2] Download + detect panorama.py → reassemble each panorama from GSV tiles
curb_ramp.py → run RampNet, extract peak detections
│
▼
[3] Write predictions <area>.jsonl → one JSON line per processed panorama
│
▼
[4] Submit (optional) send_to_ps.py → POST each record to Project Sidewalk
| File | Role |
|---|---|
main.py | Entry point for stages 1–3. Finds panos, runs detection, writes JSONL. |
panorama.py | Reassembles a full equirectangular panorama from individual GSV tiles. |
detectors/curb_ramp.py | Wraps the projectsidewalk/rampnet-model HuggingFace model. |
send_to_ps.py | Stage 4. Reads a JSONL file and submits predictions to Project Sidewalk. |
example_geojson/ | Example area polygons (bend.geojson, chicago.geojson, vancouver.geojson). |
Read this before you start. This repo is only the last link in a longer chain. It generates curb-ramp predictions and submits them to a Project Sidewalk server — but it cannot create the city on that server. The submission endpoint (
/ai/submitLabelsOnPano) only accepts labels for a city that already exists in the database, with its regions and street network in place.
So deploying Bend (or any city) is really two separate projects in two separate repos:
| Where | What | Repo | |
|---|---|---|---|
| A. Stand up the city (prerequisite) | Server-side | Create the DB schema, import neighborhood/region boundaries and the OSM street network, configure the city, deploy the server | SidewalkWebpage |
| B. Generate + submit AI labels (this repo) | Your machine | Detect curb ramps across GSV imagery and POST them to that server | this repo |
This is the larger, multi-step effort — mostly QGIS data prep — documented on the PS wiki. Roughly:
make create-new-schema name=sidewalk_bend_or.qgis_region table (with region_id).qgis_road table (with road_id).make fill-new-schema builds street_edge, region,
street_edge_region, region_completion, etc.config table — city_center_lat/lng, SW/NE map boundaries,
open_status, zoom — plus cityparams.conf, UI translations, Google Analytics ID, and
whitelisting the server URL on the Google Maps/Street View API key.check_streets_for_imagery.py then
make hide-streets-without-imagery.pg_dump the schema and provision server infrastructure with IT.Reference wiki pages:
The PS wiki estimates this stage takes significant effort (but we are trying to streamline it), dominated by QGIS data cleaning. If a Bend instance already exists, skip all of this and just point
ENDPOINT_URL(insend_to_ps.py) at it.
The polygon you feed main.py defines where panoramas are searched for. For the
submitted labels to land on real streets, that search area should describe the same Bend
as the regions/street network in the Project Sidewalk database. Mismatched boundaries mean
detections with nowhere valid to attach.
A CUDA-capable GPU is strongly recommended — running the detector on CPU is very slow.
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
On linux-64 that pulls a CUDA-enabled PyTorch wheel by default, so the GPU is used with no extra step. On Windows/macOS the default wheel is CPU-only — install the CUDA build first:
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu126
pip install -r requirements.txt
conda works too (
conda env create -f environment.yml && conda activate sidewalk-auto-labeler), but it buys nothing here: no dependency comes from a conda channel, and the CUDA runtime already arrives as thenvidia-*-cu12pip wheels torch depends on.environment.ymlpins only the interpreter and defers torequirements.txt, which is the single source of truth for versions — pin things there, and don'tconda env exportover it.
The environment pins a CUDA 12.6 build of PyTorch. On first run, the RampNet model (~hundreds of MB) is downloaded from HuggingFace and cached locally.
python main.py example_geojson/bend.geojson --name bend --scan-only
This scans coverage tiles only (no model load, no processing) and reports how many
panoramas are in the polygon plus a rough runtime estimate — so you know whether you're
committing to minutes or days before launching. On a resumed run it also tells you how
many panos remain. The scan is saved to runs/<name>/scan.json, so the real run can skip
the tile pass with --reuse-scan. That flag is off by default on purpose: coverage
changes over time, and a reused scan misses any panoramas published since it ran (the
manifest records the scan's age). Use it for a scan-then-run pair, not for a resume weeks
later.
python main.py example_geojson/bend.geojson --name bend
This writes all per-area state to runs/bend/ (--name defaults to the GeoJSON filename
stem):
runs/bend/
results.jsonl # one prediction record per processed panorama
already_processed.txt # resume cache
manifest.json # geometry hash, model provenance, per-run stats
area.geojson # exact copy of the geometry used
scan.json # last coverage scan's pano list (for --reuse-scan)
spot_check/ # optional visual-QA gallery (see below)
The run:
detections list). This is intentional: it
records that the pano was inspected.The run is resumable and cached (see below), so it's safe to stop and restart.
Both steps accept --source mapillary to run on Mapillary
360° imagery (CC BY-SA) instead of GSV — e.g. for cities where Project Sidewalk deploys
on Mapillary coverage:
# Client token from mapillary.com/dashboard/developers — export it, or put
# MAPILLARY_ACCESS_TOKEN=MLY|... in a ./.env file (gitignored, loaded automatically).
export MAPILLARY_ACCESS_TOKEN="MLY|..."
python main.py example_geojson/richmond.geojson --name richmond --source mapillary --scan-only
python main.py example_geojson/richmond.geojson --name richmond --source mapillary
Coverage comes from Mapillary's z14 vector tiles (only 360° panoramas are kept —
is_pano), imagery from the Graph API's full-resolution thumb_original_url. Because
contributors re-drive the same streets, coverage is spatially thinned to the best pano
per grid cell (newest capture, quality-score tiebreak; --thin-spacing N meters,
default 5 m, 0 disables) before processing. A run
directory is bound to one source the same way it's bound to one geometry; use a
different --name per source.
Pano positions are checked on every run, and the submitter refuses a failed check.
Mapillary serves two positions per
image: the camera's GPS fix (geometry) and an SfM-corrected one (computed_geometry).
The run submits one of them (--mapillary-position sfm|raw, default sfm, recorded in
the manifest). SfM is usually the better position, but its alignment to GPS is one
transform per reconstruction, so a whole sequence can sit several metres off the street as
a block — in Laurens, IA every intersection's labels landed 8–10 m west
(SidewalkWebpage#5361) —
and every label placed from a pano inherits that pano's error one-to-one. Which field is
right is a per-city, per-sequence question, so it is measured rather than assumed: main.py
runs the check at the end of every run (--no-position-check skips it on a host without
internet egress), and send_to_ps.py refuses a Mapillary file whose check is missing, was
made before the file last changed, or is flagged (--ignore-position-check overrides, for a
case you have looked at by hand). To re-run it, or to confirm a repositioned file:
python scripts/position_check.py runs/richmond --report
This scores every pano against OpenStreetMap street centerlines (one Overpass query,
cached beside the run; no imagery, no GPU, no second source needed), reports each
field's offset distribution and per-sequence bias, writes position_check.json and a
self-contained position_report.html (interactive map, offset histograms, per-sequence
table — both are git-tracked beside manifest.json), and exits non-zero when a sequence
is off the street on the submitted field and switching to the other field would move it
at least 2 m closer (a swap forces a new submission campaign, so it has to buy
something; sequences that sit off the street in both fields — wide one-way streets
driven once — are reported separately). A flagged run is repaired without re-detecting:
python scripts/reposition.py runs/richmond/results.jsonl --from-check # flagged sequences only
python scripts/reposition.py runs/richmond/results.jsonl --field raw # whole file
python scripts/position_check.py runs/richmond --results runs/richmond/results.check.jsonl # confirm
which writes a new results file with the pano positions rewritten from the other field
(the detections are stored relative to the pano, so nothing else changes; the heading is
SfM-derived too, but the measured discrepancies are translations, so it stays). The new
file has a new hash, so send_to_ps.py treats it as a fresh campaign — the labels already
on the server from the old positions have to be retired there first. Check and submit it
where it is: it is a submission artifact, not a run, and swapping it into results.jsonl
would let a later resume append panos on the manifest's field to a mixed file.
--source panoramax runs on Panoramax, the federated open
street-level imagery commons started by IGN and OpenStreetMap France (CC BY-SA 4.0 on most
instances, Etalab 2.0 on IGN's). No token is needed:
python main.py example_geojson/bayonne.geojson --name bayonne --source panoramax --scan-only
python main.py example_geojson/bayonne.geojson --name bayonne --source panoramax
Coverage comes from the federation catalog's z15 vector tiles (only equirectangular
pictures are kept), imagery from each picture's hd asset on its home instance — the
original upload, unsigned. Thinning works as for Mapillary (newest capture per cell,
pixel-density tiebreak). PANORAMAX_API_URL points a run at a single instance (e.g. a
self-hosted one) instead of the federation; it's checked against the instance's STAC
landing page at startup, so a mistyped root fails before the model loads rather than
producing a run that scans every tile and finds nothing. Coverage is overwhelmingly French today; the
Bayonne and Lyon boundaries in example_geojson/ are two communes with dense, recent 360°
coverage.
Ground-truth review and precision/recall scoring live in RampNet,
not in this repo. The GT gallery (scripts/gt_gallery.py) and scorer (rampnet.validation +
scripts/score_validation.py) run there on a self-contained per-city benchmark bundle under
RampNet's benchmark/<city>/. Because only this repo can fetch imagery, its one job in that
flow is to export the bundle — native-resolution panos plus the detection records:
python scripts/export_benchmark.py runs/bend/results.jsonl --out <bundle>/panos
Hand that bundle to RampNet; do the gallery review and scoring there. (This is production
tooling only — the validation half was migrated out; see CLAUDE.md.)
# Preview the transformed payloads without sending anything:
python send_to_ps.py runs/bend/results.jsonl --dry-run
# Send a handful first and look at them in the Project Sidewalk interface:
python send_to_ps.py runs/bend/results.jsonl --endpoint https://your-ps-server/ai/submitLabelsOnPano --limit 3
# Then the rest (the capped run above is already recorded, so this picks up where it left off):
python send_to_ps.py runs/bend/results.jsonl --endpoint https://your-ps-server/ai/submitLabelsOnPano
This reads each JSONL line and POSTs it to the Project Sidewalk endpoint. It also converts
the normalized detection coordinates from step 1 into pixel coordinates
(pano_x, pano_y) using the panorama dimensions stored in each record.
PS_INTERNAL_API_KEY — either inline
(PS_INTERNAL_API_KEY=… python send_to_ps.py …) or in a gitignored .env (copy
.env.example) — and it is sent as an Authorization: Bearer header. Point
--api-key-env at a different variable to keep several instances' keys side by side.
If the variable is unset, no auth header is sent and the server answers 401.https://. With a key set, send_to_ps.py refuses a
cleartext remote --endpoint before sending anything, so a mistyped URL can't leak the
key to every hop in between. http://localhost:… stays allowed — loopback (and a local
server's 0.0.0.0 bind address) never reaches the wire.--limit N stops after N records. Already-submitted lines don't count
against it, so repeated capped runs walk the file. Use it to verify a new city end to end
— placement, pano rendering, street snapping — before committing thousands of labels.<file>.submitted
sidecar, so re-running skips them instead of re-POSTing. A git-tracked
<file>.submission.json records, per endpoint, what those lines were (sha256 and byte
length of the JSONL) and how many labels went where; the script refuses to run when the two
disagree — an edited file, a lost sidecar, or a sidecar whose lines went to a different
server — so a whole city can't be duplicated, or skipped, silently. Moving from a test
instance to production means moving the sidecar aside, not deleting it.main.py --gap-fill-only appends to results.jsonl, so
"submit, gap-fill, submit the rest" is an ordinary resume: the recorded lines keep their
numbers. The run proves it by re-hashing the recorded byte prefix and checking that no
appended pano id was already submitted, reports how many new lines it found, and re-records
the new hash and length. A file that shrank, whose already-submitted lines changed, or that
re-appends panos already sent (a doubled file, or a re-run after already_processed.txt was
lost) still refuses.The detection coordinates are stored normalized (
0–1) in the JSONL and only converted to pixels at submission time. If you change the coordinate handling on one side, update the other.
You already have a Bend, Oregon polygon at example_geojson/bend.geojson. To run a new city
from scratch:
Polygon or MultiPolygon, either
bare or wrapped in a Feature/FeatureCollection (as geojson.io
exports it). main.py extracts the geometry before hashing, so wrapping does not change
which run a file belongs to, and a collection of several features is dissolved into one
MultiPolygon. A quick source for city limits is
OSM Nominatim: search the city with
polygon_geojson=1, take the boundary=administrative result, and save its geojson
field to a file. Ideally, though, derive the boundary from the
target Project Sidewalk instance's own regions so the two areas match exactly (see
Keep the two areas aligned).example_geojson/ (or anywhere) and run
python main.py path/to/city.geojson --name <city>.python scripts/export_benchmark.py runs/<city>/results.jsonl --out <bundle>/panos) and
review/score it in RampNet.send_to_ps.py at runs/<city>/results.jsonl and the target Project Sidewalk
server, then submit.--scan-only gives the pano count and a runtime estimate in a
minute or two. Throughput is GPU-bound (~1–2 s/pano; inference is serialized), so a
large city is a multi-day single-GPU run regardless of concurrency settings.main.py are the main performance knobs:
COVERAGE_API_CONCURRENCY = 100 — parallel workers scanning map tiles for pano IDs.PROCESSING_CONCURRENCY = 50 — parallel workers downloading + running detection.nohup (e.g. nohup python main.py ... &);
nohup.out is git-ignored.The per-city checklist for expanding beyond the example cities (and open questions like threshold policy and boundary source of truth) is tracked in issue #13.
Two host-specific runbooks live in docs/:
production-deployment.md — the full multi-host
production path: the Slurm cluster setup where the city runs are detected, why the
native-res imagery is archived on a separate file server, measured throughput, and the
operational gotchas. Note that nothing here deploys automatically — no webhooks, no
CI/CD, no cron; every stage is launched by hand.makeability-quickstart.md — the RA-facing happy path
for a single-box run on the lab's makelab2 GPU server.Only detection (main.py without --scan-only) needs the GPU box. Everything else
runs on a laptop (macOS/Windows/Linux) with just internet access — install via
requirements.txt (the plain CPU pip install torch torchvision is fine since the model
never runs; or skip torch entirely and install requirements-test.txt, which covers every
non-detection tool):
| Works on a laptop | Needs |
|---|---|
main.py --scan-only (scope a new city) | network only |
scripts/export_benchmark.py (native-res bundle for RampNet GT) | network + the run's results.jsonl |
send_to_ps.py | network + results.jsonl |
pytest | nothing else |
The catch: results.jsonl is git-ignored (it's tens of MB per city) and lives only on
the machine that ran the detection — copy it over first (e.g.
scp makelab2:.../runs/bend/results.jsonl runs/bend/). The small irreplaceable files —
manifest.json, area.geojson, and hand-labeled *_verdicts.json — are tracked in git
and sync normally.
Each run directory is bound to the SHA-256 hash of its GeoJSON geometry, recorded in
runs/<name>/manifest.json. Progress is tracked in runs/<name>/already_processed.txt.
--name, or restore the
geometry from the run's area.geojson. To fully re-run an unchanged area, delete its
runs/<name>/ directory.manifest.json also records model provenance (model_repo, model_revision, model_id,
training date, api_version — at top level and in each run entry), the streetlevel
version, and per-run counts (found/processed/skipped/failed) — so a months-old results file
is self-describing.--name), so one results.jsonl never
mixes two checkpoints. Manifests from before revisions were recorded resume with a one-time
note and are bound on that resume.manifest.json,
area.geojson, and *_verdicts.json (hand-labeled ground truth) — while results.jsonl
and the resume cache stay local. Archive full-city results.jsonl files as release assets
rather than committing them.Each line of results.jsonl is a JSON object like:
{
"detections": [
{ "x_normalized": 0.61, "y_normalized": 0.78, "confidence": 0.91 }
],
"label_type": "CurbRamp",
"model_id": "rampnet-model@606a11956743",
"model_training_date": "08-21-2025",
"api_version": "1.0.0",
"model_repo": "projectsidewalk/rampnet-model",
"model_revision": "606a11956743f7eb328d9207769034752f6191f4",
"pano": {
"panorama_id": "…",
"capture_date": "2021-06",
"width": 16384,
"height": 8192,
"tile_width": 512,
"tile_height": 512,
"lat": 44.05,
"lng": -121.31,
"camera_heading": 123.4,
"camera_pitch": 1.2,
"camera_roll": 0.3,
"copyright": "© Google",
"source": "launch",
"history": [ { "pano_id": "…", "date": "2019-08" } ],
"links": [ { "target_gsv_panorama_id": "…", "yaw_deg": 90.0, "description": "…" } ],
"camera_make": null,
"camera_model": null,
"camera_type": "equirectangular",
"source_detail": "launch",
"uploader": "Google",
"source_metadata": { "elevation": 1103.5, "country_code": "US", "…": "…" },
"camera_height_m": 2.21,
"camera_height_status": "measured",
"…": "…"
}
}
Notes:
detections is empty ([]) when the panorama was processed but no curb ramps were found.
Camera heading / pitch / roll are written in degrees (converted from the
radians returned by the metadata source).
copyright is an attribution ingredient, not a finished attribution. On Mapillary and
Panoramax it holds the contributor's bare name (the creator's username, or the
picture's producer), null when the source names nobody, and the licence travels beside
it in license — Project Sidewalk composes the ©, the provider and the licence itself
when it shows its own copy of the imagery. The "© Google" above is a GSV record, where
the string is Google's own and is shown verbatim.
Every source writes the same provenance keys: camera_make, camera_model,
camera_type and a source_metadata object. Mapillary and Panoramax fill make/model
from the camera; GSV exposes none, so they are null there, and its analogue is
source_detail (streetlevel's raw source: launch/scout for Google's own capture,
photos:… for a user upload) plus uploader. GSV's source_metadata is an explicit
projection of the streetlevel metadata — uploader, uploader_icon_url, upload_date,
elevation (m), country_code, street_names, address, building_level,
building_levels, places, artworks, neighbors (ids only) — every key present,
null when Google did not send it ([] for building_levels/neighbors, which
streetlevel defaults to an empty list), angles in degrees. GSV records written before
issue #23 have none of these keys.
source is coerced to Project Sidewalk's pano_source enum on submission (launch →
gsv). Project Sidewalk stores the pano's source_metadata verbatim, capped at 64 KB, so
a GSV pano submits only its imagery provenance there — uploader, uploader_icon_url,
upload_date, elevation, country_code — plus the raw source string as
source_detail (filled from source for older records, which submit the same keys with
null values). The bulky context (street names, address, building levels, places,
artworks, neighbors) stays in results.jsonl. Mapillary and Panoramax source_metadata
is submitted unchanged. send_to_ps.py refuses, before the POST, any record whose
submitted source_metadata is over 64 KB.
Model provenance is resolved from the weights actually loaded, never declared (#39):
| Field | Where it comes from | Stored by PS |
|---|---|---|
model_revision | The Hugging Face commit SHA of the loaded snapshot (config._commit_hash, else the hub cache's snapshots/<sha> directory — works offline) | no (passed through, ignored) |
model_repo | detectors.MODEL_REPO | no (passed through, ignored) |
model_id | rampnet-model@<first 12 hex of model_revision> — the old id as a prefix, so string matches on rampnet-model keep working, and a different checkpoint is a different id | label_ai_info.model_id (TEXT) |
model_training_date | Looked up by SHA in detectors.KNOWN_REVISIONS; MM-DD-YYYY because that is what PS parses | label_ai_info.model_training_date |
api_version | detectors.API_VERSION, the version of this record format | label_ai_info.api_version |
A revision not in KNOWN_REVISIONS refuses to start and prints the SHA to add (see the
comment on the table for how). --allow-unknown-model-revision runs it anyway with
model_training_date: null, recorded in the manifest — and send_to_ps.py refuses such a
file, since PS requires the date. Adding the SHA to the table afterwards does not rescue
that run: it refuses to resume (its undated lines would stay undated), so re-run the area
under a fresh --name. Lines written before #39 say "model_id": "rampnet-model" with no model_repo/model_revision; they came from the same paper weights
and submit unchanged.
A small pytest suite covers the parts whose output Project Sidewalk consumes: the JSONL
record format and the normalized→pixel transform in send_to_ps.py. No test touches the
network or loads the model. (Validation-scoring and gallery tests moved to RampNet with
their tooling.)
pip install -r requirements-test.txt # light deps only, no torch needed
pytest
CI runs the same suite on every push (.github/workflows/tests.yml).
.
├── main.py # Stage 1–3: find panos, detect, write JSONL
├── panorama.py # GSV panorama download (via streetlevel)
├── detectors/
│ └── curb_ramp.py # RampNet model wrapper
├── send_to_ps.py # Stage 4: submit predictions to Project Sidewalk (refuses a failed position check)
├── position_check.py # Pano positions vs OSM centerlines; run by main.py at the end of every run
├── position_report_template.html
├── scripts/
│ ├── export_benchmark.py # Native-res imagery bundle for RampNet GT/benchmark
│ ├── position_check.py # Shim: `python scripts/position_check.py runs/<city> --report` re-runs the check
│ ├── reposition.py # Switch a Mapillary run's pano positions between GPS and SfM, no re-detect
│ └── visual_check.py # Single-pano coordinate spot check
├── tests/ # Pytest suite (light deps only; no network, no model)
├── example_geojson/ # Example area polygons (Bend, Chicago, Vancouver)
├── environment.yml # Conda env: pins python only, defers to requirements.txt
├── requirements.txt # Pip requirements — the single source of truth for pins
├── requirements-test.txt # Test/laptop deps (everything except torch)
└── runs/ # Per-area results + resume state (git-ignored)
The code in this repository is released under the MIT License. The RampNet model it downloads from HuggingFace and the imagery it processes carry their own terms.