Walking routes that trade a little extra distance for measurably lower risk exposure — with a risk surface you can take apart layer by layer.
Inspecting each risk layer, routing UBC → downtown at 23:00, then raising the detour tolerance to 3.0: +3.5% distance for −10.4% exposure (9.29 km vs 8.98 km; mean danger 36/100 vs 40/100). The tooltip breaks any hex into its four weighted contributions — including Stanley Park, where crime reads "no data" rather than "safe".
Still frame — Kerrisdale → Strathcona at 02:00
The chosen route (orange) trades +1.2% distance for −10.4% exposure against the shortest path (grey), over the night-time composite risk surface (9.23 km vs 9.12 km; mean danger 43/100 vs 48/100).
- Overview
- Key features
- Architecture
- Tech stack
- Project structure
- Prerequisites
- Getting started
- Configuration
- API reference
- The risk model
- Validation — does it actually predict anything?
- What the data actually supports
- Testing
- Deployment
- Limitations and roadmap
- Data sources and attribution
- License
Every routing app answers the same question: what is the fastest way there? Walking home at 2 a.m., that is often the wrong question — but "safest route" apps tend to answer the opposite question badly, with an opaque score, a red-and-green map, and no way to tell whether the detour was worth it.
Safe Route Vancouver takes the middle position. One slider moves continuously from shortest to safest. Whatever you pick, the app draws both routes and states the trade in one line — "18% longer, 41% lower exposure" — so the choice stays yours and stays auditable.
Underneath is a risk surface built from four open-data layers over 1,569 H3 hexagons: reported violent crime, pedestrian collisions, street lighting, and "eyes on the street" (venues open late plus transit frequency at the hour you asked about). Night is determined by Vancouver's real sunrise and sunset, so a route at 14:00 and the same route at 02:00 genuinely differ.
Three things make this more than a heatmap with a router bolted on:
- The score is interpretable. Hover any hex and it breaks the score into its four weighted contributions. The weights are exposed in the UI and can be re-tuned live, because they are judgment calls rather than fitted values — and saying so is more honest than hiding them.
- It is validated where validation is possible. The collision layer is backtested on held-out years: ROC AUC 0.886, with the top 10% of hexes capturing 49% of future collisions. The crime layer cannot be validated this way, and the app says so rather than borrowing the credibility.
- The data's limits shape the design. Vancouver PD redacts every violent-crime location. Rather than fake block-level precision, the crime layer stays at neighbourhood resolution and the UI discloses it. See What the data actually supports.
Important
Personal / portfolio project. Not a safety product. It estimates reported incident density, not risk. See Limitations.
| Continuous safety/distance trade-off | One α slider from shortest path to safest. Edge cost is length · (1 + α · danger), so the trade is smooth, not a mode switch. |
| Always shows the counterfactual | Every result draws the chosen route and the plain shortest path, with distance, walking time, and mean exposure for both. |
| Real time-of-day | Darkness and emptiness penalties switch on at Vancouver's actual sunrise/sunset, computed from the USNO almanac algorithm — no external API, no fixed clock hour. |
| Interpretable hexes | Hover any hex for its per-factor contribution breakdown, the neighbourhood, and the incident rate per 1,000 residents. |
| Layer inspector | View the composite or any single input layer on one perceptually-uniform, colourblind-safe ramp. |
| Live weight tuning | Re-score the whole city and the current route from the UI. Both endpoints accept weight overrides, normalized server-side. |
| Honest "no data" | Hexes with no census population (Stanley Park, Musqueam) render grey with weights renormalized — never as "safe". |
| Shareable links | Full state lives in the URL hash, so any comparison can be sent as a link and restored cold. |
| Refuses nonsense input | Clicks in the harbour or outside the city are rejected with a readable message instead of silently snapping to shore. |
| Measured, bounded claims | A temporal backtest with a bandwidth sensitivity sweep — and an explicit statement of what it does not license. |
The shape of the system follows one constraint: startup may be slow, requests may not.
- Ingest is batch and idempotent. Each layer is an independently runnable module that percentile-ranks its signal onto one shared H3 res-9 grid and writes a parquet. Missing layers degrade gracefully rather than crashing.
- Scoring is one pure function.
composite_danger()is used by both the per-hex map surface and the per-edge routing cost, so the heatmap and the router can never disagree. - Edges are annotated once. The walk graph gets its four raw layer values at startup; the hour-dependent composite is evaluated per request. That is what makes the time slider interactive without re-annotating 100k+ edges every time it moves.
Full design rationale, including the decisions that changed once the real data arrived, is in docs/PRD.md — the spec the source comments cite by section.
Backend · Python 3.11 · FastAPI · uv · NetworkX (Dijkstra) · OSMnx (walk graph) · H3 (spatial index) · GeoPandas / Shapely · NumPy / pandas / SciPy · PyArrow (parquet)
Frontend · React 18 · Vite · deck.gl (H3 hexagon + GeoJSON layers) · MapLibre GL · CARTO basemap tiles
Quality · pytest · Vitest + Testing Library · Ruff (lint + format) · ESLint · GitHub Actions
.
├── ingest/ # One runnable module per data layer -> data/processed/*.parquet
│ ├── crime.py # VPD violent crime -> neighbourhood choropleth
│ ├── collisions.py # Pedestrian collisions -> point KDE
│ ├── lighting.py # Street lighting -> poles per 100 m of walkable street
│ ├── venues.py # OSM open-late venues -> KDE
│ ├── transit.py # TransLink GTFS -> departures per hex per hour
│ ├── census.py # Population denominator for per-capita rates
│ ├── network.py # OSMnx walk graph, cached as GraphML
│ └── geo.py # Shared fixed-bandwidth KDE + percentile ranking
├── core/ # Pure scoring and routing — no I/O, no framework
│ ├── config.py # Every tunable: weights, bandwidths, H3 resolution, α range
│ ├── scoring.py # composite_danger() and the per-hex surface
│ ├── routing.py # Edge annotation, cost function, Dijkstra + baseline
│ ├── solar.py # Real sunrise/sunset (USNO algorithm, no dependencies)
│ └── route_cli.py # CLI: two coordinate pairs -> GeoJSON + stats
├── api/ # FastAPI app + environment-driven settings
├── web/ # React + deck.gl frontend
├── analysis/ # Temporal backtest + KDE bandwidth sensitivity sweep
├── tests/ # Synthetic-city fixtures + real-artifact integration tests
└── docs/ # PRD, architecture diagram, hero image
| Requirement | Version | Notes |
|---|---|---|
| Python | 3.11+ | Managed by uv; you don't need a system Python of the right version |
| uv | latest | Install — curl -LsSf https://astral.sh/uv/install.sh | sh |
| Node.js | 20.19+ or 22.12+ | Required by Vite 7 (developed against 22) |
| make | any | Optional — every target maps to a plain command shown below |
| Disk | ~1 GB | Raw downloads plus the cached walk graph |
| Network | required, once | First run downloads from VPD, OpenStreetMap, City of Vancouver, and TransLink |
Note
No API keys are needed anywhere. Every source is open data and the basemap is keyless.
git clone https://github.com/lumixed/SafeMaps.git
cd SafeMaps
make bootstrapmake bootstrap installs both toolchains and builds every data layer in dependency order. Expect
10–20 minutes on first run — most of it downloading the VPD archive and building the OSMnx walk
graph. It is idempotent, so re-running is cheap and safe.
Then, in two shells:
make apimake webOpen http://localhost:5173. Click the map to set A then B, or hit "a 2pm walk" to see the mechanic immediately.
Tip
The API loads the graph and annotates every edge at startup (~7 s locally). It logs
startup complete when it is ready to serve.
Install dependencies
uv sync --all-groups
cd web && npm install && cd ..Build the data layers (order matters — everything aligns to the M0 hex grid)
# 1. Census population denominator. Must come FIRST: ingest.crime reads it to
# build per-capita rates, and silently falls back to raw-count ranking
# (a different, worse layer) if it is missing.
uv run python -m ingest.census
# 2. Crime: downloads the VPD archive and defines the canonical hex grid
# every other layer aligns to.
uv run python -m ingest.crime --download --render
# 3. Walk graph: downloads Vancouver's walking network from OpenStreetMap.
uv run python -m ingest.network
# 4. Remaining layers (collisions reuse the archive downloaded in step 2).
uv run python -m ingest.collisions
uv run python -m ingest.lighting --download
uv run python -m ingest.venues
uv run python -m ingest.transit --downloadEvery module is independently runnable and idempotent. Raw and processed data are gitignored and never committed.
Run it
# API on :8000
uv run uvicorn api.main:app --port 8000 --reload
# Frontend on :5173 (Vite proxies /api -> :8000)
cd web && npm run devRoute from the command line, no browser
uv run python -m core.route_cli \
--origin 49.2627 -123.1207 \
--dest 49.2695 -123.0700 \
--alpha 2.0 \
--hour 2Prints GeoJSON for both routes to stdout and a one-line summary to stderr.
make checkRuns both linters and both test suites — the same thing CI runs.
Model tunables (weights, KDE bandwidths, H3 resolution, α range) live in
core/config.py. Deployment knobs come from the environment:
| Variable | Default | Purpose |
|---|---|---|
SAFEROUTE_CORS_ORIGINS |
localhost dev servers | Comma-separated allowed origins, or * |
SAFEROUTE_LOG_LEVEL |
INFO |
Standard logging level name |
SAFEROUTE_PRELOAD_HOURS |
none | Comma-separated hours to warm the hex cache at startup |
PORT |
8000 |
Port the container's uvicorn binds |
VITE_API_BASE |
empty | Build-time API origin for the frontend — see web/.env.example |
Readiness probe. Reports what is actually loaded, not just liveness.
{ "status": "ok", "hexes": 1569, "graph_nodes": 57360, "graph_edges": 171036,
"cached_hours": [2, 14], "version": "1.0.0" }The whole risk surface for one hour: composite danger, the four weighted factor contributions, the raw
layer values, and neighbourhood metadata. Optional w_crime, w_dark, w_empty, w_collision
override the weights (normalized server-side).
Unscored values serialize as null, never 0 — the client renders them as no data.
Returns the chosen route, the alpha=0 baseline, and the comparison:
{
"route": { "type": "LineString", "coordinates": [[-123.12, 49.26], ...] },
"baseline": { "type": "LineString", "coordinates": [[-123.12, 49.26], ...] },
"stats": {
"length_m": 3520.4, "baseline_length_m": 3478.1,
"mean_danger": 0.31, "baseline_mean_danger": 0.35,
"length_delta_pct": 1.2, "danger_delta_pct": -10.8
},
"hour": 2, "is_night": true
}Endpoints outside the city, or more than 250 m from any walkable street, return 422 with a readable message rather than a confident-looking nonsense route.
Interactive docs are served at /docs when the API is running.
danger(h,t) = w_crime·crime_pct + w_dark·dark(h,t) + w_empty·empty(h,t) + w_collision·collision_pct
dark(h,t) = (1 - lighting_pct) if night else 0
empty(h,t) = (1 - eyes_pct(t)) if night else 0
eyes_pct(t) = max(open_venue_pct, transit_pct[t])
w(edge) = length(edge) · (1 + α · mean danger over the hexes it traverses)
Every layer is percentile-ranked into [0,1] before entering the sum — incident densities have heavy
right tails, and without ranking one extreme hex flattens the rest of the city.
| Layer | Construction | Why it's built that way |
|---|---|---|
| Crime | Neighbourhood choropleth, as a rate per 1,000 residents (2016 census) | Locations are redacted — see below. Ranking rates rather than counts moves Strathcona above the CBD and drops high-population Kitsilano ~30 percentile points. |
| Collisions | Gaussian KDE from points, 150 m bandwidth | Real intersection-anchored coordinates, so tighter smoothing is justified — and the backtest confirms it. |
| Lighting | Poles per 100 m of walkable street | Poles per hex would just measure how much road a hex contains. Street length comes from the walk graph. |
| Venues | Open-late (past 22:00) OSM points, KDE-smoothed | Raw hex counts are too zero-inflated to rank. A missing opening_hours tag is unknown, never closed. |
| Transit | GTFS departures per stop per hour, KDE-weighted, ranked within each hour | Frequency at the queried hour, not stop presence: 4 buses/hour at 23:00 is a different signal from no night service. |
Default weights are crime 0.40, dark 0.25, empty 0.20, collision 0.15 — documented judgment calls,
not fitted values. There is no ground-truth "danger" label to fit against, and pretending otherwise
would be the most misleading thing this project could do. Because they are judgment, they are editable
live in the ⚙ Weights panel: a reader who disagrees can re-score the city instead of arguing with a
constant.
The honest question about any risk map: does it predict the future, or just describe the past?
Only the collision layer can answer — it is the one danger input with real coordinates and real dates. Train the KDE surface on 2003–2020, then test it against 2021–2025 collisions it never saw:
make validate # -> data/processed/backtest_collisions.png| Metric | Result |
|---|---|
| ROC AUC | 0.886 (0.5 = no skill) |
| Top 10% of hexes capture | 49% of held-out collisions |
| Top 20% of hexes capture | 68% |
| Spearman (predicted vs actual) | 0.77 |
A companion sweep (analysis/bandwidth_sensitivity.py) shows skill
rising monotonically as the KDE bandwidth shrinks (AUC 0.90 @ 100 m → 0.75 @ 900 m), because collisions
are intersection-anchored. The layer therefore uses 150 m — hex-scale, keeping most of the gain
without tuning to the single best held-out value. Venues and transit keep 300 m: ambient "eyes on the
street" is genuinely diffuse.
What this does and does not license. It shows the collision layer has real predictive skill. It says nothing about the crime layer, and nothing about the composite as a whole. The claim is bounded on purpose.
The most interesting constraint in this project is one the original spec got wrong.
The plan assumed violent-crime records arrive as offset points that could be KDE-smoothed to 174 m hexes. They do not:
- Every
Offence Against a Personrecord is redacted:X = Y = 0,HUNDRED_BLOCK = "OFFSET TO PROTECT PRIVACY",HOUR = 0. - VPD's FAQ confirms it — no time or street location is published for these offences.
- The offset points exist only in VPD's interactive map, not in anything downloadable.
So violent crime is available at neighbourhood resolution only (24 areas). The layer is a choropleth: violent count per neighbourhood, converted to a per-capita rate, percentile-ranked, painted flat across the hexes each neighbourhood covers.
We deliberately do not KDE-smooth neighbourhood centroids. That would fabricate sub-neighbourhood precision the data cannot support — exactly the failure the smoothing rules were written to prevent. The UI discloses the resolution rather than hiding it behind a smooth gradient.
Neighbourhood geography is recovered from the property-crime points in the same file — they carry real coordinates and the same 24 neighbourhood labels — so no external boundary file or name reconciliation is needed. Property crime is used only to assign hexes to neighbourhoods, never as a risk input.
Collisions do carry real points and hours, which is why they, and only they, support the point-KDE and the backtest.
make test # both suites
make test-py # pytest
make test-web # vitestThe suite is deliberately split so that a fresh clone with no data and no network still runs a real test suite:
- Synthetic city fixtures (
tests/conftest.py) build a lattice of streets over a hand-made danger surface with a dangerous "wall" through the middle. The shortest path crosses it; a high-αroute must bend around it. This exercises the real edge annotation, the real composite, and the real Dijkstra weight callable — in ~0.3 s, with no downloads. - API contract tests run the real FastAPI app through
TestClientwith only the graph and layer loaders swapped for those fixtures, covering serialization, validation, caching, and weight overrides. - Property tests pin the invariants that matter: exposure decreases monotonically with
α, night is never safer than day,α=0reproduces a plain shortest path exactly, and NaN never serializes as0. - Integration tests against the real Vancouver graph and real VPD data skip cleanly when the
artifacts are absent, and run in full once
make datahas been executed.
CI runs lint, format check, and both suites on every push and pull request.
Two pieces with very different shapes: a static frontend (trivial) and a stateful API that holds the whole graph in memory (the part that needs thought).
Measured on the real Vancouver build, not estimated:
| Peak memory at startup | 445 MB (loading the graph) |
| Memory, idle after startup | ~120 MB |
| Memory, all 24 hourly surfaces cached | 205 MB |
| Data files required at runtime | ~105 MB |
| Cold start | 3.0 s on a laptop — longer on shared vCPU |
Response size, /api/hexes |
530 KB raw → 56 KB gzipped |
Size the instance on the startup peak, not the steady state. Idle memory is ~120 MB, but loading the walk graph transiently needs far more, and that is what gets a container OOM-killed. Give it 2 GB.
Warning
The graph is cached in two forms. The pickle (walk_graph.pkl) loads in 0.9 s and peaks at 445 MB.
The GraphML fallback (walk_graph.graphml) parses 67 MB of XML and peaks at 1.1 GB — five times
the size of the graph it produces. A 1 GB machine survives the first and is killed by the second, so
the box must be sized for the fallback unless you remove it.
Important
Do not deploy the API to serverless (Vercel/Netlify functions, Lambda). It loads a 57k-node graph and annotates every edge at startup, then keeps it in memory. Every cold invocation would repay that cost, and the memory ceiling and execution limits make it a bad fit. Use a persistent container.
Dockerfile.bundled builds the frontend, bakes in the data layers, and serves
both from one FastAPI process. The client already calls same-origin /api paths, so one container
serves the whole product: one URL, no CORS to configure, no VITE_API_BASE at build time, and no
volume to provision.
make data # once, ~10-20 min
docker build -f Dockerfile.bundled -t safe-route-yvr .
docker run -p 8000:8000 safe-route-yvr # -> http://localhost:8000Deploy to Fly.io with the committed fly.toml:
fly launch --no-deploy --copy-config # first time only, pick your app name
fly deployIt pins one always-on shared-cpu-1x / 1 GB machine in sea (closest region to Vancouver), health
checks /api/health with a 180 s grace period, and pre-warms hours 2, 14 and 21. Render, Railway and
Cloud Run all take the same image; only the config file differs.
If you would rather put the frontend on a CDN, build it against a remote API and host web/dist/
anywhere static:
cd web && VITE_API_BASE=https://your-api.example.com npm run buildThen set the API's allowed origin — a missing value here is the most common cause of a map that loads but stays empty:
SAFEROUTE_CORS_ORIGINS=https://your-frontend.example.comIn this mode use the plain Dockerfile (API only, data mounted at runtime with
-v "$PWD/data:/app/data:ro"). Either way the API refuses to start without the layers rather than
serving an empty map.
Most free tiers idle a container out after inactivity. Because startup re-annotates every edge, a
portfolio link that has been idle overnight can take tens of seconds to answer the first request —
worth knowing before you send it to anyone. Options: a paid always-on instance, an uptime pinger
hitting /api/health, or simply accepting it and saying so on the page.
Warning
The TransLink GTFS feed is licensed for non-commercial use, and the baked layers derive from OpenStreetMap (ODbL). Deploying for your own use is fine; publishing the bundled image to a public registry is redistribution and the upstream terms apply. See LICENSE.
Known limitations — stated because a risk model that hides them is worse than none:
- Reported incidents ≠ risk. Reporting rates vary by area and by offence type.
- The violent-crime category is a broad aggregate that includes domestic incidents, which say little about risk to someone walking past.
- The census denominator counts residents, so it misses commuters, visitors, and nightlife crowds — a large undercount downtown.
- Weights are judgment calls, not fitted values.
- The crime layer is not validated and cannot be, given redacted locations.
- Coverage stops at the Vancouver city line.
Possible next steps
- Isochrone view — "everywhere within a 20-minute low-exposure walk"
- Per-layer opacity blending instead of one-at-a-time inspection
- Elevation-aware costs (Vancouver's hills matter for walking time)
- Extend to Metro Vancouver where equivalent open data exists
- Server-side route caching keyed by (origin, destination, α, hour)
| Source | Used for | Licence |
|---|---|---|
| VPD GeoDASH open data | Violent crime, pedestrian collisions | Public domain |
| City of Vancouver Open Data | Street lighting poles, census local-area profiles | Open Government Licence – Vancouver |
| Statistics Canada, Census 2016 | Population denominator (custom order for CoV Local Areas) | Statistics Canada Open Licence |
| OpenStreetMap | Walking network, open-late venues | © OpenStreetMap contributors, ODbL |
| TransLink GTFS static | Transit service frequency | © TransLink — non-commercial use only |
| CARTO basemaps | Map tiles | © OpenStreetMap contributors © CARTO |
MIT for the source code. The data it ingests is separately licensed by its publishers and none of it is redistributed in this repository — see the LICENSE file for the full breakdown.


{ "origin": [49.2627, -123.1207], // [lat, lng] "destination": [49.2695, -123.0700], "alpha": 2.0, // 0 = shortest path, 3 = max detour "hour": 2, "weights": { "crime": 0.5, "dark": 0.3, "empty": 0.1, "collision": 0.1 } // optional }