ProjectSidewalk/sidewalk-auto-labeler

HTML

1

580 commits

updated Oct 18, 2026

See the code

README

Sidewalk Auto-Labeler

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:

  1. Find every GSV panorama inside that area.
  2. Download each panorama and run the RampNet curb-ramp detection model on it.
  3. Write one prediction record per panorama to a .jsonl file.
  4. (Optional second step) POST those predictions to a Project Sidewalk server.

How this relates to RampNet

This repo is the deployment / inference tool. The actual machine-learning model lives elsewhere:

  • ProjectSidewalk/RampNet — the research project that trained the model. It generated a dataset of 210,000+ annotated GSV panoramas (bootstrapped from government curb-ramp location data) and trained a panorama-level detector on a ConvNeXtV2 backbone.
  • 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.

How it works

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

Components

FileRole
main.pyEntry point for stages 1–3. Finds panos, runs detection, writes JSONL.
panorama.pyReassembles a full equirectangular panorama from individual GSV tiles.
detectors/curb_ramp.pyWraps the projectsidewalk/rampnet-model HuggingFace model.
send_to_ps.pyStage 4. Reads a JSONL file and submits predictions to Project Sidewalk.
example_geojson/Example area polygons (bend.geojson, chicago.geojson, vancouver.geojson).

Prerequisites: a Project Sidewalk city instance must exist first

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:

WhereWhatRepo
A. Stand up the city (prerequisite)Server-sideCreate the DB schema, import neighborhood/region boundaries and the OSM street network, configure the city, deploy the serverSidewalkWebpage
B. Generate + submit AI labels (this repo)Your machineDetect curb ramps across GSV imagery and POST them to that serverthis repo

What "stand up the city" (A) involves

This is the larger, multi-step effort — mostly QGIS data prep — documented on the PS wiki. Roughly:

  1. Create the schema — make create-new-schema name=sidewalk_bend_or.
  2. Prepare geo data in QGIS (EPSG:4326 WGS 84):
    • Neighborhood/region boundaries → qgis_region table (with region_id).
    • OSM street network (filtered road classes, split at intersections, single-linestrings) → qgis_road table (with road_id).
  3. Populate tables — make fill-new-schema builds street_edge, region, street_edge_region, region_completion, etc.
  4. Configure the 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.
  5. Prune streets without imagery — check_streets_for_imagery.py then make hide-streets-without-imagery.
  6. Deploy — 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 (in send_to_ps.py) at it.

Keep the two areas aligned

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.

Setup

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 the nvidia-*-cu12 pip wheels torch depends on. environment.yml pins only the interpreter and defers to requirements.txt, which is the single source of truth for versions — pin things there, and don't conda env export over 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.

Usage

Step 0 — Scope the area first

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.

Step 1 — Run the labeler over an area

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:

  • Scans the map tiles that come within 50 m of the polygon (a concave or multi-part area skips its empty corners; the 50 m margin is there because a coverage tile can return panoramas lying just outside its own bounds), keeping only panoramas whose location falls inside the polygon.
  • Skips indoor panoramas.
  • Writes one line per successfully processed panorama — including panoramas where zero curb ramps were found (those get an empty 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.

Alternative imagery source: Mapillary

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.

Alternative imagery source: Panoramax

--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.

Step 2 — Validate the detections (in RampNet)

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.)

Step 3 — Submit predictions to Project Sidewalk

# 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.

  • Auth: Project Sidewalk's ingest endpoint requires that instance's internal API key. Put it in 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.
  • Never commit the key. It is a shared server secret, and it is per-instance — a key for one city's server will not authenticate to another's. Ask that server's maintainers for it.
  • Remote endpoints must be 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.
  • Staged rollout: --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.
  • Resumable: successfully submitted line numbers are recorded in a <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.
  • A gap-fill is not an edit. 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.
  • Transient failures (connection errors, 5xx) are retried with backoff; 4xx responses are treated as permanent and logged.

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.

Deploying to a new city

You already have a Bend, Oregon polygon at example_geojson/bend.geojson. To run a new city from scratch:

  1. Create a GeoJSON polygon for the city boundary: a 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).
  2. Save it under example_geojson/ (or anywhere) and run python main.py path/to/city.geojson --name <city>.
  3. To validate, export a benchmark bundle (python scripts/export_benchmark.py runs/<city>/results.jsonl --out <bundle>/panos) and review/score it in RampNet.
  4. Point send_to_ps.py at runs/<city>/results.jsonl and the target Project Sidewalk server, then submit.

Tips for a large city

  • Scope it first: --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.
  • The two concurrency settings at the top of 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.
  • For long unattended runs, launch with 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.

Where we actually run this

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.

Working away from the GPU machine

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 laptopNeeds
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.pynetwork + results.jsonl
pytestnothing 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.

Caching & resumability

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.

  • Panoramas already listed there are skipped on re-runs.
  • The JSONL output and the cache are appended and flushed line-by-line, so an interrupted run loses almost nothing.
  • Skipped panoramas (indoor sources, missing/incomplete metadata) are deterministic, so they are cached too — they won't be refetched on re-runs (and produce no JSONL line).
  • Failed panoramas are deliberately not cached, so they are retried on the next run.
  • Re-running a name with an edited geometry is refused (the hash no longer matches the manifest) instead of silently mixing two areas' state — use a new --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.
  • A run directory is likewise bound to one model revision: resuming with a different Hugging Face revision loaded is refused (use a new --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.
  • Git tracks the small, irreplaceable files in each run directory — 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.

Output format

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):

    FieldWhere it comes fromStored by PS
    model_revisionThe 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_repodetectors.MODEL_REPOno (passed through, ignored)
    model_idrampnet-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 idlabel_ai_info.model_id (TEXT)
    model_training_dateLooked up by SHA in detectors.KNOWN_REVISIONS; MM-DD-YYYY because that is what PS parseslabel_ai_info.model_training_date
    api_versiondetectors.API_VERSION, the version of this record formatlabel_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.

Tests

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).

Requirements

  • Python 3.12 (provided by the conda environment).
  • A CUDA GPU (recommended) or CPU.
  • Internet access to the imagery source (Google Street View, Mapillary or Panoramax) and HuggingFace.
  • A running Project Sidewalk server with the target city already set up (only for the submission step) — see Prerequisites.

Project layout

.
├── 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)

License

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.

ProjectSidewalk/sidewalk-auto-labeler

HTML

1

580 commits

updated Oct 18, 2026

See the code

README

Sidewalk Auto-Labeler

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:

  1. Find every GSV panorama inside that area.
  2. Download each panorama and run the RampNet curb-ramp detection model on it.
  3. Write one prediction record per panorama to a .jsonl file.
  4. (Optional second step) POST those predictions to a Project Sidewalk server.

How this relates to RampNet

This repo is the deployment / inference tool. The actual machine-learning model lives elsewhere:

  • ProjectSidewalk/RampNet — the research project that trained the model. It generated a dataset of 210,000+ annotated GSV panoramas (bootstrapped from government curb-ramp location data) and trained a panorama-level detector on a ConvNeXtV2 backbone.
  • 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.

How it works

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

Components

FileRole
main.pyEntry point for stages 1–3. Finds panos, runs detection, writes JSONL.
panorama.pyReassembles a full equirectangular panorama from individual GSV tiles.
detectors/curb_ramp.pyWraps the projectsidewalk/rampnet-model HuggingFace model.
send_to_ps.pyStage 4. Reads a JSONL file and submits predictions to Project Sidewalk.
example_geojson/Example area polygons (bend.geojson, chicago.geojson, vancouver.geojson).

Prerequisites: a Project Sidewalk city instance must exist first

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:

WhereWhatRepo
A. Stand up the city (prerequisite)Server-sideCreate the DB schema, import neighborhood/region boundaries and the OSM street network, configure the city, deploy the serverSidewalkWebpage
B. Generate + submit AI labels (this repo)Your machineDetect curb ramps across GSV imagery and POST them to that serverthis repo

What "stand up the city" (A) involves

This is the larger, multi-step effort — mostly QGIS data prep — documented on the PS wiki. Roughly:

  1. Create the schema — make create-new-schema name=sidewalk_bend_or.
  2. Prepare geo data in QGIS (EPSG:4326 WGS 84):
    • Neighborhood/region boundaries → qgis_region table (with region_id).
    • OSM street network (filtered road classes, split at intersections, single-linestrings) → qgis_road table (with road_id).
  3. Populate tables — make fill-new-schema builds street_edge, region, street_edge_region, region_completion, etc.
  4. Configure the 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.
  5. Prune streets without imagery — check_streets_for_imagery.py then make hide-streets-without-imagery.
  6. Deploy — 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 (in send_to_ps.py) at it.

Keep the two areas aligned

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.

Setup

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 the nvidia-*-cu12 pip wheels torch depends on. environment.yml pins only the interpreter and defers to requirements.txt, which is the single source of truth for versions — pin things there, and don't conda env export over 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.

Usage

Step 0 — Scope the area first

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.

Step 1 — Run the labeler over an area

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:

  • Scans the map tiles that come within 50 m of the polygon (a concave or multi-part area skips its empty corners; the 50 m margin is there because a coverage tile can return panoramas lying just outside its own bounds), keeping only panoramas whose location falls inside the polygon.
  • Skips indoor panoramas.
  • Writes one line per successfully processed panorama — including panoramas where zero curb ramps were found (those get an empty 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.

Alternative imagery source: Mapillary

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.

Alternative imagery source: Panoramax

--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.

Step 2 — Validate the detections (in RampNet)

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.)

Step 3 — Submit predictions to Project Sidewalk

# 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.

  • Auth: Project Sidewalk's ingest endpoint requires that instance's internal API key. Put it in 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.
  • Never commit the key. It is a shared server secret, and it is per-instance — a key for one city's server will not authenticate to another's. Ask that server's maintainers for it.
  • Remote endpoints must be 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.
  • Staged rollout: --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.
  • Resumable: successfully submitted line numbers are recorded in a <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.
  • A gap-fill is not an edit. 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.
  • Transient failures (connection errors, 5xx) are retried with backoff; 4xx responses are treated as permanent and logged.

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.

Deploying to a new city

You already have a Bend, Oregon polygon at example_geojson/bend.geojson. To run a new city from scratch:

  1. Create a GeoJSON polygon for the city boundary: a 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).
  2. Save it under example_geojson/ (or anywhere) and run python main.py path/to/city.geojson --name <city>.
  3. To validate, export a benchmark bundle (python scripts/export_benchmark.py runs/<city>/results.jsonl --out <bundle>/panos) and review/score it in RampNet.
  4. Point send_to_ps.py at runs/<city>/results.jsonl and the target Project Sidewalk server, then submit.

Tips for a large city

  • Scope it first: --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.
  • The two concurrency settings at the top of 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.
  • For long unattended runs, launch with 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.

Where we actually run this

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.

Working away from the GPU machine

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 laptopNeeds
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.pynetwork + results.jsonl
pytestnothing 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.

Caching & resumability

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.

  • Panoramas already listed there are skipped on re-runs.
  • The JSONL output and the cache are appended and flushed line-by-line, so an interrupted run loses almost nothing.
  • Skipped panoramas (indoor sources, missing/incomplete metadata) are deterministic, so they are cached too — they won't be refetched on re-runs (and produce no JSONL line).
  • Failed panoramas are deliberately not cached, so they are retried on the next run.
  • Re-running a name with an edited geometry is refused (the hash no longer matches the manifest) instead of silently mixing two areas' state — use a new --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.
  • A run directory is likewise bound to one model revision: resuming with a different Hugging Face revision loaded is refused (use a new --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.
  • Git tracks the small, irreplaceable files in each run directory — 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.

Output format

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):

    FieldWhere it comes fromStored by PS
    model_revisionThe 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_repodetectors.MODEL_REPOno (passed through, ignored)
    model_idrampnet-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 idlabel_ai_info.model_id (TEXT)
    model_training_dateLooked up by SHA in detectors.KNOWN_REVISIONS; MM-DD-YYYY because that is what PS parseslabel_ai_info.model_training_date
    api_versiondetectors.API_VERSION, the version of this record formatlabel_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.

Tests

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).

Requirements

  • Python 3.12 (provided by the conda environment).
  • A CUDA GPU (recommended) or CPU.
  • Internet access to the imagery source (Google Street View, Mapillary or Panoramax) and HuggingFace.
  • A running Project Sidewalk server with the target city already set up (only for the submission step) — see Prerequisites.

Project layout

.
├── 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)

License

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.