Turns Street View frames scored by gpt-oss-120b into a scenic street map, builds time-budgeted loop tours inside Waymo's service area, and serves them (with ElevenLabs audio) to the frontend.
Needs Python 3.11+ (built on 3.13). Run everything from Server/.
python -m venv .venv
.venv\Scripts\Activate.ps1 # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
copy .env.example .env # fill in keys as you get them
python test_router.py # no keys needed, prints "ok"Run the API with no keys at all (fake scores, good for frontend work):
python -m pipeline.sample --hood wynwood little_havana # first run downloads OSM, ~2 min, cached after
python -m pipeline.score --fake
python -m pipeline.rollup
python -m pipeline.matrix
uvicorn app.main:app --reload # http://localhost:8000/docsTry it: POST /route with {"mood": "murals+sunset", "minutes": 30, "start": "wynwood"}.
The real pipeline (needs GOOGLE_MAPS_API_KEY + LLM_API_KEY): same as above but replace score --fake with
python -m pipeline.streetview # downloads frames (costs money, resumable)
python -m pipeline.score # gpt-oss-120b rates each frame (costs money, resumable)Start with python -m pipeline.sample --hood wynwood --limit 500 to test cheaply.
Then delete any old data/tours/*.json (tours are cached by mood+minutes+start and keep the scores they were built from).
Bake the demo (needs all keys, do this before going on stage):
python -m pipeline.bake --mood murals+sunset --minutes 30 --start wynwoodThen GET /tour/murals_sunset-30-wynwood is a pure file/DB read: no live API calls.
service_area.geojson --> graph.py (OSM, no highways) --> graph.graphml
sample.py --> points.json --> streetview.py --> media/frames/*.jpg
--> score.py (gpt-oss-120b) --> frames.json --> rollup.py --> segments.json
segments.json + graph --> matrix.py --> matrix_<mood>.json
POST /route: matrix + segments + graph --> tour --> data/tours/<id>.json (+ Mongo)
background: Places/Wikipedia + gpt-oss-120b script + ElevenLabs --> media/audio/*.mp3
GET /tour/{id}: reads the stored tour. Nothing else.
| Endpoint | What it does |
|---|---|
GET /health |
{ok, narration, mongo}: whether narration keys / Mongo are configured |
GET /config |
moods, languages, start neighborhoods (feed the pickers); sources.{photo,popular} has each scenery source's starts, moods and by_start (the moods that build a tour from each start) |
GET /service-area |
the traced polygon as a GeoJSON Feature (outline layer) |
GET /segments?bbox=minLng,minLat,maxLng,maxLat&source=photo |
scored ~100 m street pieces as GeoJSON (score, tags, street) for the green-to-gray map; bbox optional; source=popular gives the popular-online scores (plus places) |
(any POST /route body) source |
"photo" (default): Street View frames rated by AI. "popular": places people map and look up online, every street in the service area, from python -m pipeline.popular (data/popular/). A Safer tour also carries compare: the Safer-OFF route's path, the High Injury pieces it drives and this one avoids (avoided_hin, avoided_km, avoided_ksi, corridors) |
GET /photos |
every street piece with a downloaded Street View frame as GeoJSON (photo, street, date, pano lat/lng). Needs only sample + streetview, not score; the map uses it for click-a-street-to-see-it |
POST /route {mood, minutes, start, language, lat?, lng?} |
builds (or returns the cached) one-way tour from start, or from lat/lng if given (400 if outside the service area, 422 if no photo-covered street is within 250 m); starts narration in language in the background. Returns {tour_id, options}: options is the top 10 routes, ranked by mean stop score (half-point steps) first, then share of the drive inside the service area, then drive time; tour_id is #1 and each option's id loads with GET /tour/{id} (which also has origin: the start's photo, street and description). Only #1 is narrated automatically |
GET /tour/{id} |
full tour: path (GeoJSON LineString, lng/lat), frames[] for ride mode, stops[] (with frame_idx, script{lang}, audio{lang}), summary. Poll it while audio generates |
POST /tour/{id}/narrate?lang=es |
language toggle: generate another language, then poll GET /tour/{id} |
/static/frames/*.jpg, /static/audio/*.mp3 |
stored media, referenced by the URLs inside a tour |
Moods: murals water art_deco historic food sunset surprise murals+sunset. Languages: en es ht pt. Starts: wynwood little_havana.
Interactive docs at /docs.
| File | What it is |
|---|---|
app/config.py |
All env vars, paths, and the constants everyone shares: MOODS (mood to tags), HOODS (demo neighborhood bbox + loop start), LANGS, TAGS, SPEED_FACTOR. Change moods/neighborhoods here. |
app/llm.py |
LiteLLM OpenAI-compatible client for gpt-oss-120b. Ranking and tour scripts both go through here. |
app/identify.py |
Muse Glimmer (LiteLLM vision) → subject for Places lookup. |
app/places_rating.py |
Google Places text/nearby search → star rating + review count. |
app/verify_logic.py |
Stars×2 → /10; compare to AI score → verify_status. |
app/verify_frame.py |
One-frame orchestration (identify → resolve → compare). |
app/graph.py |
Loads the drivable street graph. Uses data/graph.graphml if present, else pulls OSM inside the polygon and saves it. Highways are excluded by an inclusion filter of road classes (primary down to residential), so motorway/trunk/service roads are never in the graph. Applies SPEED_FACTOR to travel times. |
app/router.py |
Pure route logic, no I/O. top_candidates picks the best-scored segments for a mood (>= 250 m apart); build_loop does cheapest insertion by score / added_minutes until the time budget is used. Tested by test_router.py. |
app/tour.py |
Builds a one-way tour from segments.json + the graph: the start (a neighborhood default, or a spot the user picked) snaps to the nearest photo-backed street piece; the destination is the user's pick or a scenic block for the mood reached within the time budget. The route is the single shortest road start -> destination (no detours, no cycles, nothing before the start or after the end); the numbered stops are the best blocks on it, and the drive is checked against the budget. Real path geometry via Dijkstra, ride-mode frames, stops. narrate_tour fills script + audio per stop. Tour id = <mood>-<minutes>-<start> plus -safe, -from<lat>_<lng>, -to<lat>_<lng> when set; a stored tour without a destination (older loop format) is rebuilt. |
app/narrate.py |
The external calls for a stop: Places (nearby business rated >= 4.0), Wikipedia (nearest article), gpt-oss-120b (20 s script, told to use only the given facts), ElevenLabs (eleven_multilingual_v2 MP3). |
app/store.py |
Saves/loads tours. Always writes data/tours/<id>.json; also mirrors to Mongo (waymotour.tours) when MONGO_URI is set, and reads Mongo first. Stops are embedded in the tour document, not a separate collection, so /tour/{id} is one read. |
app/main.py |
The FastAPI app: endpoints above, CORS from CORS_ORIGINS, static media. Narration only runs if LLM + ElevenLabs keys + voice id are set. |
| Script | Reads | Writes | Notes |
|---|---|---|---|
sample.py |
graph | data/points.json |
~100 m pieces inside the polygon, one heading each: travel direction +90° (out the right window; --side left|ahead to change), plus travel_heading and length_m. --hood limits to demo neighborhoods, --limit N for smoke tests. |
validate.py |
graph, points.json |
(exit code) | Import checklist: polygon valid and ~50-70 sq mi, no motorway/trunk, strongly connected, travel times > 0, points inside polygon, median segment ~100 m. Run after sample. |
streetview.py |
points.json |
data/media/frames/<id>.jpg |
Free metadata check first, so no-imagery spots cost nothing. Skips frames already downloaded. |
score.py |
points.json, frames |
data/frames.json |
gpt-oss-120b, JSON {score 1-10, tags[]}. Resumable, saves every 25. --fake invents scores (no keys). |
verify.py |
frames.json, JPGs |
data/frames.json |
Glimmer identify → Google stars → review_score (/10, or -1), verify_status. Does not change AI score. check.py is the same with default --min-score 7. |
rollup.py |
points.json, frames.json |
data/segments.json |
Averages frame scores per segment, keeps the segment geometry. |
matrix.py |
segments.json, graph |
data/matrix_<mood>.json |
Per mood: top-30 candidates + the demo starts, Dijkstra drive minutes between all pairs. The router looks times up here instead of running Dijkstra per request. |
popular.py |
graph, OpenStreetMap places (Overpass), Wikipedia page views | data/popular/segments.json, pois.json, matrix_<mood>.json |
The no-photos scenery source: scores every street 1-10 from the attractions, museums, murals, monuments, parks, marinas and restaurants within 60 m, Wikipedia-read places weighted up. Free, no keys, ~2 min. Rerun after the graph changes. |
bake.py |
all of the above + keys | data/tours/<id>.json, data/media/audio/*.mp3 |
Builds a tour and narrates it in every language for the stage demo. |
test_router.py: self-check for the insertion algorithm, mood filtering, and that the demo starts are inside the polygon. Run python test_router.py.
| File | Made by | Size | In git? | Notes |
|---|---|---|---|---|
service_area.geojson |
traced from Waymo's launch-post map | tiny | yes | The initial Jan 2026 launch area (54 sq mi; Waymo says ~60), extracted from the map image and georeferenced against OSM highway junctions, ~50 m accuracy. It does not include the later Miami Beach / Hard Rock Stadium expansions. To change it, save a new polygon over this file, delete graph.graphml, rerun the pipeline. Say "traced from Waymo's published service map, approximate" on stage. |
graph.graphml |
graph.py (first run) |
~20 MB | no | OSM street graph. Regenerated automatically; delete it to refetch (after changing the polygon or ROADS). |
points.json |
sample.py |
~0.4 MB per 1.8k points | yes | Segment skeleton: ids (u_v_i), coordinates, heading, street name. |
frames.json |
score.py |
small | yes | Every scored frame: score + tags. The only thing the ranking model's output lands in. |
segments.json |
rollup.py |
small | yes | What the router and /segments read. Only scored segments exist. |
matrix_<mood>.json |
matrix.py |
~35 kB each | yes | The drive-time "stash". Rebuild after re-scoring. |
tours/<id>.json |
/route, bake.py |
~0.1-1 MB | yes | Stored tours, also the offline fallback for the demo. Delete one to force a rebuild. |
media/frames/*.jpg |
streetview.py |
~50 kB each | no | Street View images. Not committed (size and Google's terms). Copy to the droplet with rsync/scp. |
media/audio/*.mp3 |
narrate_tour |
~100 kB each | no | ElevenLabs audio, named <tour>-<stop>-<lang>.mp3. Copy to the droplet after baking. |
| File | What it is |
|---|---|
requirements.txt |
Pinned deps from pip freeze. Reused by the Dockerfile later. |
.env.example |
Every env var with a note. Copy to .env (gitignored). |
Full walkthrough with commands: ../DIGITALOCEAN.md. In short: an Ubuntu 24.04 droplet, uvicorn under systemd (deploy/waymo-api.service), Caddy for HTTPS (deploy/Caddyfile), data/media/ and graph.graphml copied over with scp, CORS_ORIGINS set to your Vercel origins, NEXT_PUBLIC_API_URL=https://api.<yourdomain> set in Vercel, and the droplet's IP allowed in Mongo Atlas.
AREA_BUFFER_MILES grows the traced Waymo polygon (54 sq mi) in every direction: 1 mile = ~91 sq mi, 3 miles = ~180, 4 miles = ~233, 10 miles = ~680. The street graph, the sampled blocks and the "start/end must be inside the area" check all use the grown shape, and /service-area (the map outline) shows it. Stops only exist where a frame has been downloaded and scored, so growing the area adds nothing to tours until you run the steps below.
Street pieces (~100 m each) measured from the OSM graph. Only ~600 pieces have frames today.
| Buffer | Pieces in the whole area | Pieces in the new ring only | Images to download for the ring | Cost after the free 10,000 |
|---|---|---|---|---|
| 1 mile | 34,370 | 8,826 | up to 8.8k | $0 |
| 3 miles | 55,163 | 29,619 | up to 29.6k | about $138 |
| 4 miles | 67,720 | 42,176 | up to 42.2k | about $225 |
- Google Static Street View: the first 10,000 images each month are free, then $7.00 per 1,000 up to 100,000 (then $5.60). The metadata check the tool runs first is free and unlimited, so spots with no imagery cost nothing; the figures above are upper bounds.
- Scoring is one LLM call per frame (
LLM_API_KEY), billed by your LLM provider. - Check what you already used this month: Cloud Console -> APIs & Services -> Street View Static API.
- Size: the 4-mile graph is 32k nodes / 85k edges (~2.5x the traced one) and downloads from OSM in under 3 minutes. 10 miles is several times bigger and untested. A 2 GB droplet is probably fine up to 4 miles; 4 GB is the safe choice.
The example is the 1-mile ring, which fits in one free month. Needs GOOGLE_MAPS_API_KEY and LLM_API_KEY in Server/.env.
1. Set the size and rebuild the street graph (free; a few minutes)
# in Server/.env: AREA_BUFFER_MILES=1
del data\graph.graphml
python -c "from app import graph; graph.build()"Writes data/graph.graphml for the grown area. Skipping the delete keeps the old graph and finds no new streets.
2. Sample only the new area (free; prints the frame count)
python -m pipeline.sample --ring --mergeExpect: N points -> data/points.json; M have no frame yet (at most $X ...). --ring keeps only pieces outside the traced polygon; --merge keeps the points you already have (without it points.json is replaced and rollup would drop every block you scored before). Other choices: --bbox W S E N --merge for one box (lng/lat degrees), or --merge alone for the whole grown area including the traced part.
3. Download the frames (billable after 10,000 images per month)
python -m pipeline.streetview --limit 10000Expect: K points to fetch (of N)... then J new frames from K points. It skips frames already on disk, checks free metadata first, and stops after 10,000 image downloads. If it stops at the limit, rerun next month (or drop --limit and pay) to continue; it resumes where it left off.
4. Score the new frames (one LLM call each; Ctrl+C is safe, rerun resumes)
python -m pipeline.scoreExpect: X already scored, Y to go... then Z scored frames -> data/frames.json. Already-scored frames are skipped.
5. Rebuild what the API reads
python -m pipeline.rollup # data/segments.json
python -m pipeline.matrix # data/matrix_<mood>.json, one per mood
del data\tours\*.json # cached tours keep the old scores; this also deletes baked demo tours (rerun pipeline.bake)6. Check it
python -m pipeline.validate # its area check compares the traced shape, not the grown one
python test_router.py # prints "ok"
uvicorn app.main:app --reloadOpen http://localhost:8000/segments (new blocks appear) and http://localhost:8000/config (moods and starts). Optionally rerun python -m pipeline.safety so the safety layer covers the bigger area.
7. Put it on the server (if deployed; see ../DIGITALOCEAN.md)
scp Server/data/graph.graphml waymo@<IP>:/home/waymo/WaymoMapProject/Server/data/
scp -r Server/data/media waymo@<IP>:/home/waymo/WaymoMapProject/Server/data/Commit and pull the data/*.json files, set the same AREA_BUFFER_MILES in the droplet's .env, then sudo systemctl restart waymo-api.
Repeat for a bigger area or another month: change AREA_BUFFER_MILES, rerun steps 1-7 (the graph rebuild and --ring --merge pick up the new ring; already-downloaded and already-scored frames are never redone).
Service area traced from Waymo's published service map: approximate, not official. Street data © OpenStreetMap contributors (ODbL).
- The neighborhood boxes (
HOODS) are rough. Wynwood, Little Havana, Overtown and the Design District are inside the traced polygon; Little Haiti is not (the polygon's north edge is ~NW 46th St), so don't pitch it as a stop. - ElevenLabs
eleven_multilingual_v2may not support Haitian Creole. Testhtearly and pick another voice/model or drop the language if the audio is wrong. - Ranking, Glimmer vision, and scripts use LiteLLM with
LLM_API_KEY+LLM_BASE_URL(your team’s LiteLLM proxy athttps://<host>/v1orhttp://127.0.0.1:4000/v1locally).sk-…keys are proxy virtual keys only. Glimmer uses the sameapp/llm.pypath as gpt-oss-120b with a differentIDENTIFY_MODEL. Runpython -m pipeline.llm_checkafter setting the base URL to list models on the proxy. - Google's Maps Platform terms restrict caching/storing Street View imagery and Places data. Keep it to the demo neighborhoods and don't publish the dataset.
- Route times come from OSM speeds times
SPEED_FACTOR; calibrateSPEED_FACTORagainst a real ride time. - Sunset is approximated as waterfront + greenery tags (no sun position yet).
- Tours are cached by
(mood, minutes, start). After re-scoring, deletedata/tours/(and the Mongo docs) or you will keep getting the old route. - Not built yet: containerized AI runs, ESP32 mood dial, locals' block stories, user-drawn custom paths.