Four standalone Python utilities for Project Sidewalk. They are not part of the running web app's request path
(except as noted below) — they are run out-of-band. check_streets_for_imagery.py, onboard_city.py and
street_gradient.py resolve their data/output paths relative to the repo root, so they can be launched from any
working directory. Unit tests for all four live in test/python/.
The web container ships two Pythons, mirroring prod (makelab1 runs the app on the OS's Python; user accounts have a current one):
| Script | Interpreter | Dependencies |
|---|---|---|
label_clustering.py |
python3 (3.8) |
requirements.txt |
check_streets_for_imagery.py |
python3.13 |
requirements-offline-tools.txt |
onboard_city.py |
python3.13 |
requirements-offline-tools.txt |
street_gradient.py |
python3.13 |
requirements-offline-tools.txt |
label_clustering.py is shelled out to by the running app, so it must work on whatever python3 the server has —
currently 3.8, which is EOL (#4396). Offline tooling has no such tie and runs on python3.13; host-side, ≥ 3.11.
Clusters a region's accessibility labels by type and posts the results back to the app.
This one is invoked in-band: ClusterService.runMultiUserClustering
(app/service/ClusterService.scala) shells out to it once per
region when an admin triggers clustering at /runClustering (and on the nightly ClusteringActor schedule). The script GETs the region's labels from
/labelsToClusterInRegion, clusters each label type independently (complete-linkage hierarchical clustering over
haversine distance, with per-type distance thresholds; labels from the same user+pano are never clustered together),
makes the cluster ids globally unique, and POSTs the labels, clusters, and thresholds back to /clusteringResults.
INTERNAL_API_KEY=<internal-api-key> python3 scripts/label_clustering.py --region_id <id> [--debug]INTERNAL_API_KEY(env) — the internal API key, sent as anAuthorization: Bearerheader (kept off the command line so it can't leak intops/access logs). The app passesconfig.get[String]("internal-api-key").--region_id— the region whose labels to cluster.--debug— print per-type cluster counts and coordinate-cleaning stats.SIDEWALK_HTTP_PORT(env) — app port, defaults to9000.
Because this one runs in-band, the deployed app has to be able to both find and run it: scripts/ is bundled
into the staged package via Universal / mappings in build.sbt and ClusterService resolves it
against the app root (not the process working directory), and its requirements.txt
dependencies must be installed in the python3 interpreter the app shells out to.
Finds streets lacking street-view imagery (Google Street View, Mapillary, Panoramax, or Infra3d) and writes them to a CSV. Standalone and manual — nothing in the app calls it.
- Export a CSV of the
street_edgetable with columnsstreet_edge_id, region_id, x1, y1, x2, y2, geom(geom as WKB hex) todb/onboarding/<city-id>/street_edge_endpoints.csv. Every scan file lives in that per-city dir, so scans for different cities can't collide or resume each other's checkpoints. - Run one of (from any directory — paths resolve relative to the repo root):
It checks each street's endpoints first, then samples points along the street, and flags streets where enough points lack imagery. It writes streets without imagery to
python3.13 scripts/check_streets_for_imagery.py --city-id newport-ky --gsv # needs GOOGLE_MAPS_API_KEY python3.13 scripts/check_streets_for_imagery.py --city-id newport-ky --mapillary # needs MAPILLARY_ACCESS_TOKEN python3.13 scripts/check_streets_for_imagery.py --city-id newport-ky --infra3d # needs INFRA3D_CLIENT_ID + # INFRA3D_CLIENT_SECRET; add --campaign <uid> if # the tenant has several python3.13 scripts/check_streets_for_imagery.py --city-id newport-ky --panoramax # public API, no credential; # 360° pictures only
streets_with_no_imagery.csv, and a per-street imagery summary (presence + capture-date range) tostreet_imagery_summary.csv, both in the same dir. - Run
make hide-streets-without-imageryto mark those streets in the database.
Optional flags: --workers N (streets checked concurrently, default 8) and --max-qps F (global cap on requests per
second across all workers, default 10 — deliberately conservative; Google allows ~500/s).
Infra3d has no metadata endpoint, so --infra3d uses the nearest-frame query (framegate's knn/query) that the
vendored viewer SDK issues on every setLocation — the same request a labeler's browser makes as they move — with the
same per-city OAuth client credentials the app uses (PanoDataService.getInfra3dToken). Export one city's pair as
INFRA3D_CLIENT_ID / INFRA3D_CLIENT_SECRET (in the web container they're INFRA3D_CLIENT_ID_ZURICH,
..._WINTERTHUR, etc.); the tenant to query comes from the token's own scope, so nothing else is per-city. Two things
differ from the other providers:
- The query returns the nearest frame with no distance cap (a point far outside the city still gets the city's closest frame), so "has imagery" is decided client-side: the nearest frame within the same 25 m (endpoints) / 15 m (along-street) radius GSV uses.
- Frames are filtered server-side to 360° types (
calotte/cubemap), matching the viewer'ssetFilter— Infra3d datasets mix in flat mono/stereo photos that Explore can't label on, so a street with only flat frames counts as having no imagery. - Frames are also restricted to a campaign (one drive). The viewer restricts every query to its project's
campaigns (the
project_uidhardcoded inInfra3dViewer.js), but our credentials can't read the project, so the scan lists the tenant's campaigns at startup instead: with one campaign (every city today) it's used automatically and printed; with several, the scan lists them and stops until you pass--campaign <uid>(repeatable). Without this, frames from any other drive in the tenant would count as imagery Explore can't actually reach.
The token lives 60 minutes and is refreshed automatically during a long scan. Infra3d publishes no rate limit; the
default --max-qps 10 is in the range of a single busy browser session, so keep it there (or lower) rather than
raising it.
make check-imagery id=laurens-ia args="--sample --gsv" # 150 random streets; --sample 60 --seed 3 to vary
make check-imagery id=laurens-ia args="--sample --mapillary"The same per-street verdict on a random sample, kept under db/onboarding/<city-id>/preflight/<provider>/ so a full
scan's checkpoint is untouched, with every provider sampled so far summarized side by side in
db/onboarding/<city-id>/preflight_report.md: coverage, failed (a key or quota problem — GSV's
OVER_QUERY_LIMIT / REQUEST_DENIED now fail the street instead of counting as imagery), and the oldest / median /
newest of the covered streets' newest captures. Because onboard_city.py writes the endpoints CSV this reads, the
question "does this city have imagery, and how fresh?" is answered minutes after the build, before any database work.
A rerun with the same N and --seed resumes the sample; a different sample replaces that provider's row rather than
accumulating into it.
The responses we already fetch also carry an imagery capture date, so — for no extra API calls — the scan records
each street's capture-date range (oldest/newest) and pano count into street_imagery_summary.csv
(street_edge_id, region_id, has_imagery, oldest_capture, newest_capture, n_panos). That tells us not just whether a
street has imagery but how old it is. GSV and Infra3d each answer with a single pano, so its date is the one recorded.
Mapillary instead returns every image in the queried box, and the date recorded belongs to the image Explore would
actually display: score_pano ports the viewer's ranking (distance, resolution, recency), reading its weights from
conf/pano-scoring.json so the two can't drift. Recording the newest image instead would let a street look
freshly imaged while the viewer went on serving older panos (#4411). Persisting this into the database — to power a
"stale imagery" signal alongside the street_edge_status work (#3888) — is tracked as a separate follow-up (#4348).
The scan is built to survive a flaky network over a long run, and to scan a whole city in reasonable time:
- Concurrency: streets are checked in parallel (thread pool), but a shared token-bucket rate limiter caps total
requests/second (
--max-qps) so we stay well under the provider limit regardless of worker count. Each worker keeps the sequential endpoint→points early-exit, so concurrency doesn't inflate the number of API calls. - Retry: each request is retried with exponential backoff + jitter (
tenacity) before giving up. - Fail-soft: a street that still errors is logged and the scan continues (it no longer aborts the whole run);
the failed set is retried once at the end, and any still-failing streets are written to
failed_streets.csv. - Resume: progress is checkpointed per street to
streets_imagery_checkpoint_<provider>.csv, so a re-run resumes where it left off and re-attempts only failed/unprocessed streets — and since every city's files live in its own dir and the checkpoint is per provider, a scan can never resume another city's or another provider's results:--mapillaryafter--gsvrescans and regenerates the output CSVs from the Mapillary checkpoint. The final no-imagery CSV is derived from the checkpoint at the end — its schema is unchanged, somake hide-streets-without-imageryis unaffected.failed_streets.csvis rewritten every run (empty when nothing failed), so a rerun with a fixed key clears it. - Progress: a
tqdmprogress bar (count, %, rate, and ETA) renders to stderr as streets complete. It tracks the whole city and is seeded with already-settled streets, so a resumed run picks up at its prior percentage rather than restarting at 0%. It auto-suppresses when stderr isn't a terminal, so redirected/CI logs stay clean.
(The earlier bbox-radius unit bug and the no-op print — issue #4342 — are fixed as part of this.)
The resilience and concurrency above are adapted from Jon Froehlich's GSV Tracker — its retry/backoff, fail-soft "log-and-continue", resumable progress, and rate-aware concurrent fetching. We diverge from it on purpose, because the two tools answer different questions:
- Sampling — street-following, not a grid. GSV Tracker samples a uniform geographic grid to measure area-wide
coverage and temporal patterns. Here the question is per-street ("does this
street_edgehave usable imagery?"), so we follow each street's geometry with early-exit: far fewer API calls than gridding a whole city, and results map directly to astreet_edge(no spatial join). - Concurrency — conservative threads, not async. GSV Tracker uses
asyncio/aiohttptuned for maximum throughput (toward Google's ~500 req/s ceiling). We use a small thread pool + a token-bucket QPS cap and deliberately stay well under the limit; at that bounded concurrency, threads are simpler and sufficient and async's scale benefit is wasted. - Providers — GSV, Mapillary, and Infra3d. GSV Tracker is GSV-only.
The street_imagery table records, per street, the capture-date range of the panos observed on it (oldest_capture,
newest_capture, n_panos) so the app can flag streets whose imagery is stale — complementing street_edge_status
(#3888), which only says whether a street has imagery. The table has two feeders, distinguished by its data_source
column:
- Feeder 1 —
pano_data(automatic). Evolution326.sqlcreates the table and backfills it frompano_data(joined to streets vialabel, which carries bothpano_idandstreet_edge_id). This runs per-city on deploy at zero API cost and covers every audited street, including Mapillary/Infra3d panos. Rows are taggeddata_source = 'pano_data'. - Feeder 2 — the imagery scan (manual). For streets a scan reached but that have no labels yet (so Feeder 1 can't
see them), run
make import-street-imageryto ingestdb/onboarding/<city-id>/street_imagery_summary.csv— the per-street summary the scan writes. Rows are taggeddata_source = 'imagery_scan', and a scan supersedes an existingpano_datarow for the same street (it's a deliberate, fresher measurement).
Builds a new city's street + region staging data (qgis_road/qgis_region, consumed by
db/scripts/fill-new-schema.sh) from open data sources — the headless replacement for the QGIS onboarding runbook
(#4291). Standalone and manual; it never writes to the database. The full workflow around it, including the QGIS QA
loop and the imagery preflight, is docs/onboarding-a-city.md.
make build-city-data id=laurens-ia args="--place 'Laurens, Iowa, USA'"
make build-city-data id=bayonne-fr args="--boundary-file city.geojson --regions-file quartiers.geojson \
--region-name-col nom --regions-source 'https://…'"--city-id is the id the deployment will use in conf/cityparams.conf (SIDEWALK_CITY_ID); the schema /
DATABASE_USER swaps its hyphens for underscores (sidewalk_laurens_ia — new cities keep the full city id).
Streets come from OSM (osmnx, the runbook's highway filter minus area=yes plazas, --include-alleys for
service=alley), noded only where included ways meet. Then the #4717 anti-tiny-segment rules: pieces under
--merge-tiny-m (20 m) left between close intersections merge back into a touching piece of the same OSM way (never
closing a ring); region-boundary fragments under --heal-segment-m are reabsorbed; boundary-running splits are merged
(rider_merges QA layer); truncated ends riding within --boundary-merge-tol-m of the covered area are restored.
osmnx joins consecutive OSM ways between intersections into one edge, so a street lists every way it spans in
osm_ids (the fill records the first in osm_way_street_edge, which is one row per street). Regions come from the
first source that works: --regions-file (any OGR format/CRS; --region-name-col names its name column, and
--regions-source records the provenance in region.data_source), OSM neighbourhood polygons (auto-rejected under
75% city coverage), US census tracts (TIGERweb), or the city boundary as a single region.
Outputs land in db/onboarding/<city-id>/ (git-ignored; visible to the db container at /opt/onboarding/ when run
from the main checkout): the QA GeoPackage, qgis_tables.sql, street_edge_endpoints.csv (the scan's input, so the
preflight below runs before any database exists), and report.md with the tiny-segment histogram (production
averages 18% of streets under 20 m; Bayonne rebuilt at 4%), per-region km with SPARSE/OVERSIZED/EMPTY flags,
and boundary coverage. The QA loop: rerun with tweaked flags — --merge-regions "Census Tract 513:Census Tract 523.01" folds regions by name and re-splits the streets against the merged
boundaries, and --rename-regions applies db/onboarding/<city-id>/region_renames.csv — or hand-edit the
GeoPackage in QGIS and regenerate the SQL with make build-city-data id=<city-id> args="--from-gpkg", which
validates the layers first (a hand-built layer with a single osm_id column is accepted).
Samples a bare-earth elevation model along every street's centerline and writes each street's running slope, climb
and elevation profile to db/onboarding/<city-id>/street_gradient.csv, which make import-street-gradient loads into
the street_gradient table (#5223). The method, the measurements behind its constants, and the per-country source
table are in docs/street-gradient.md.
make export-street-gradient-input # streets with no row yet, or whose geometry changed
make street-gradient id=seattle-wa # US cities: USGS 3DEP 10 m, picked from the city's country-id
make street-gradient id=cdmx args="--dem-dir db/onboarding/cdmx/dem --dem-name inegi-mdt-5m --dem-resolution-m 5"
make import-street-gradient- Sources. A registered source is chosen from the city's
country-idinconf/cityparams.conf(only the USA so far). Anything else goes through--dem-dir: a directory of hand-downloaded GeoTIFFs in any mix of coordinate systems, elevations in meters. - Bridges and tunnels. A bare-earth model has the ground under a bridge, so streets the export marks
is_structure(fromosm_way.tags) get their endpoint elevations and no grade (quality = structure), and an untagged street whose profile holds an implausible pitch is drawn as a straight line between its endpoints (quality = suspect). - An empty
osm_waystops the export, since every bridge would then be sampled as the ground beneath it. Passargs=--allow-empty-osm-wayfor a city that really has none, andargs=--allto resample every street. - Resume. Rows are flushed a grid cell at a time;
--resumekeeps the ones that answer the current export (same street, samegeom_md5) and samples the rest. - No network in tests.
test/python/test_street_gradient.pywrites small GeoTIFFs whose elevation is a known plane, so every expected grade is arithmetic.
make test-python # both halves, in the web container
make test-python-app # just label_clustering.py, on python3 (3.8)
make test-python-tools # just the offline tooling, on python3.13See test/python/README.md for details and CI status.