Compares NYC storefronts on pedestrian traffic against what the space costs, so a small business can see which location gives the most relevant foot traffic per dollar of rent. Search or click anywhere on the map to get the nearest measured point, its history, and the storefronts around it. Pilot area: the avenues between 50th and 51st Street in Midtown Manhattan, where NYC DOT counts pedestrians at eleven points. Rents are real, from the storefront registrations owners file with the Department of Finance.
npm install
npm run dev
Then open http://localhost:5173. All data is bundled, so the demo works offline apart from the base map tiles. Click "Run the demo" for a worked example.
npm test runs the calculation tests. npm run build:data refetches the public
datasets.
A long running service sweeps all 377 Manhattan traffic cameras and counts the people at each junction, about 0.3 seconds a camera, so a full sweep takes roughly two minutes and runs every ten. The nine pilot corridor cameras are sampled every minute on top.
Frames are fed to the model at their native 352x240 rather than upscaled to the
processor default of 800px. Measured across six cameras, native is about eleven times
faster and finds roughly twice as many people: upscaling a small frame adds no detail
and costs a great deal of compute. Set TILES=3 for a slower, slightly higher count.
Other machines can share the work. The collector serves a queue on port 5301 and prints a join command when it starts; run that on any laptop or PC with Node and it will pull cameras, count them and report back. Sweeps finish roughly N times faster with N machines. A worker that sleeps or disconnects loses nothing: its cameras go back on the queue after a two minute lease.
npm run collect:worker -- http://<coordinator>:5301 <token> [name]
The token is printed at startup and stored in data/worker-token.
npm run collect:bg start it in the background
npm run collect:until 2026-08-04 run under a supervisor until a date
npm run collect:status is it alive, and how far behind
npm run collect:stop
npm run collect:snapshot fold what it gathered into the bundled demo data
npm run build:cameras refresh the Manhattan camera list
Tune the load with SWEEP_SECONDS, PRIORITY_SECONDS and CONCURRENCY.
It writes an append-only log to data/samples.jsonl and a rolled up
data/rollup.json, both outside the repo. Restarting replays the log, so history
survives. Under collect:until a supervisor restarts the collector if it dies, backing
off up to five minutes, and stops everything at the deadline. A failed cycle is logged
and skipped rather than ending the run. The app polls a small summary and fetches per camera detail only when a
camera is opened, so it never loads all 377 at once. It falls back to the bundled
snapshot when the collector is not running, saying which of the two it is showing. Any camera panel can
also count a fresh frame on demand.
npm run build
Vercel picks up vercel.json and needs no other setup: framework Vite, output dist.
Everything the app shows is bundled or static, so the map, the ranking and every saved
camera count work with nothing behind them.
Counting a fresh frame does work there, through api/detect.js. It fetches the still and
runs the model server side, which it has to: the camera host sends no access-control
headers, so a browser can display the picture but cannot read its pixels. The function
uses the quantized model, 41MB against 159MB, fetched to /tmp on a cold start. Measured
locally that is about 7 seconds cold and under a second warm. Quantizing costs a little
recall, around one person in a quiet frame and three in a busy one, always downward.
What cannot move is the collector itself, a long running process sweeping 377 cameras
every ten minutes and appending to a log. The history it has built ships as the snapshot
in public/camera-snapshot and src/data/camera.json; run npm run collect:snapshot
before deploying to refresh it. The app asks /api/detect whether counting is available
and hides the button when it is not, rather than offering something that errors.
Weekly opportunity counts pedestrians during the counted windows in a typical week (5 x weekday morning + 5 x weekday evening + 1 x Saturday). Business-hour fit turns each window into people per hour and weights those rates by the share of the day a business trades in, so it is a rate rather than a headcount. Both are divided by monthly rent in thousands, and both show their arithmetic in the storefront panel.
React, TypeScript, Vite, MapLibre GL. Data from NYC Open Data and the MTA via their Socrata APIs.
- Sidewalk pedestrian counts are real: NYC DOT Bi-Annual Pedestrian Counts, retrieved Jul 31 2026. They cover only three windows: weekdays 7-9 am, weekdays 4-7 pm, and Saturday 12-2 pm, twice a year. Every other hour is uncounted and the app shows it so.
- The hour-by-hour charts are MTA Subway Hourly Ridership, station entries at the seven stations serving the corridor since January 2025. That is people entering a station, not people walking past a storefront. It is shown separately and never mixed into the footfall figures.
- No daily or hourly sidewalk count exists for any NYC street. NYC DOT's automated pedestrian counters sit on park paths, average a few hundred people a day, and publish unvalidated data with months that read a hundred times their own median, so they are not used.
- Camera counts measure an intersection, not a blockfront. These cameras sit at junctions and point at the roadway, so they see people crossing, waiting at the kerb and turning the corner: a larger and different quantity from the people passing a shop mid-block. Camera figures are reported per junction and are not used in the storefront comparison, which rests on DOT's mid-block screenlines.
- The counts are measured by this app: an object detection model over stills from every public NYC DOT traffic camera online in Manhattan, 377 of them. Only the number of people per frame is stored. No faces, identities, demographics, vehicles or plates, and no tracking between frames or cameras. Frames are 352x240, so distant pedestrians are missed and darkness or rain lowers the count: each figure is a floor, not a total. Frames are sliced into overlapping tiles before detection, which finds substantially more of a crowd than one pass over the whole frame; where that slicing keeps finding more, the reading is labelled "at least N". Boxes that sit mostly inside a better box, or whose centres are closer than two people could stand, are folded away, since the model often draws several boxes on one person; in a packed crowd this can merge neighbours, so the busiest frames read low. Each camera also learns the expected pixel height of a person by image row and drops boxes that are the wrong size for where they sit.
- On the corridor cameras, people are followed through a burst of a dozen frames taken two seconds apart and counted once each, the way agencies do it. Anything that never moves across the burst is set aside as a fixed object. The result is a flow in people per minute through that junction. On a Saturday afternoon the two cameras nearest DOT's Fifth Avenue screenline read 83 and 69 a minute against DOT's 91, which is the right order of magnitude but not a validation: DOT counts one pavement mid-block and the camera sees the whole junction.
- Elsewhere in Manhattan counts are still per frame, which cannot tell one person from several boxes; published accuracy for that is roughly 30 to 90 percent and worst in crowds. Read those as an index of how busy a place is, not a census. Only detections above a confidence of 0.55 that are taller than they are wide and a plausible size are counted, because on a dark wet street the model will otherwise box parked cars and lens flare as people. Frames too dark to trust are flagged in the app. Detections that keep appearing in the same few pixels across frames taken minutes apart are treated as street furniture rather than people, since a pole does not move and a pedestrian does; the threshold is set so a busy pavement loses nothing, so some furniture still counts.
- Cameras are scored on whether their own daily rhythm matches Manhattan's. One pointed at
a motorway or a line of bollards does not, and is marked unusable rather than averaged
in.
npm run collect:reliabilityrecomputes it; all nine pilot corridor cameras pass. - The cameras keep no archive, only a live snapshot, so camera history starts when the
collector is switched on. Run
npm run collectto gather more. - Weekly totals, footfall per $1,000 of rent, and rent per square foot are calculated from those counts. Weekly totals assume the one counted day represents the whole week. Each storefront panel shows the arithmetic.
- Storefronts and rents come from the NYC Department of Finance storefront registry,
which owners file every year under Local Law 157 of 2019, reporting year 2024. DOF
publishes the addresses (92iy-9c3n) and the rents (dxru-eun8) as separate datasets, the
second aggregated to census tract. So a rent here is the median actually paid per square
foot in that storefront's own census tract, times the median registered size there. Two
shops in the same tract carry the same figure, which is as fine as the published rent
goes. Tracts DOF suppressed for having too few filings show "No data available".
npm run build:rentsrefetches it. - The comparison is one entry per blockfront, ten of them, listing the real addresses registered on each. Traffic is measured at one screenline per blockfront and rent no finer than a census tract, so two shops on the same blockfront would carry identical numbers. Enter a rent of your own to price a specific unit.
- Storefront coverage is the pilot corridor only. The citywide map layer shows the other DOT count locations with no storefront data attached.