Why I built it
Environmental Health asked residents for objective evidence before it would look at the noise. Complaining that "the bikes never stop" is not evidence. A count, with a timestamp and a photo for every pass, is.
The first counted day was 10 May 2026. I cleaned up the detections by hand, merging repeats and deleting anything that was not a Checkers bike, and it came to 171 passes between 10:00 and 18:00. Doing that every day by hand was never going to last, so I automated it.
The camera and the laptop
A fixed 4 MP IP camera (2688 × 1520) with a built-in microphone looks at the stop street from private property. It streams over RTSP. Video is read with OpenCV on a background thread that keeps only the newest frame, so the detector never falls behind and analyses stale video. Sound comes through a second RTSP connection with PyAV. If the stream drops, it reconnects with a backoff from 5 to 60 seconds. Timestamps come from the laptop's clock, which is kept in sync over the internet, not from the camera's overlay.
It is a decent camera, not a fast one. Riders speed through the stop street, nearly all of them, and at that speed the image smears. Quite a few passes never produce a usable detection at all.
Everything runs on one laptop:
- NVIDIA RTX 3050 Laptop GPU (4 GB) and an Intel i7-11800H.
- Ubuntu 22.04 under WSL2 on Windows.
- Python 3.10, Ultralytics 8.4, PyTorch 2.11, OpenCV 4.13, PyAV 17 and Shapely.
The detector checks the clock every minute and only works inside its recording window, currently 07:30 to 19:00. It is also my everyday laptop, sometimes needed for other work, and now and then the process hangs without crashing. Those gaps are real, and they are why the site only publishes days that were recorded in full (more on that below).
Finding the bikes: YOLO26 and ByteTrack
Detection uses Ultralytics' off-the-shelf YOLO26n (the nano model), pretrained on the COCO dataset. I keep only one of its classes: motorcycle. There is no custom detector. To save GPU time it only looks at a 1740 × 670 px region of the frame around the road, and maps the boxes back to full-frame coordinates.
- 15 frames a second. It started at 5. Fast bikes crossed the count zone between two frames and were never counted.
- Confidence threshold 0.10. It started at 0.25. This is recall-first on purpose: a bike the detector never sees cannot be rescued later, but a false detection is easy for the classifier to throw out.
- ByteTrack, through Ultralytics' built-in tracker, follows each bike across frames. I loosened its settings to suit the low threshold: high 0.15, low 0.05, new track 0.15, a 45-frame buffer and a match threshold of 0.75.
- Count zone. A rectangle on the road. The test point is the bottom centre of each box, roughly where the tyre meets the tar. An event fires once per track, the first time it is seen inside the zone.
- The crop. Each event saves a padded crop from the frame where the detector was most confident, plus a sound clip.
Nothing is classified live. The laptop is already working hard just detecting, so the heavier decisions wait until the evening.
Teaching it what a Checkers bike looks like
COCO knows what a motorcycle is, not whose it is. So I trained a second model, YOLO26s-cls (the small classifier), to answer one question about each crop: is this a Checkers Sixty60 delivery bike? It looks at a 224 px image, and what it mostly learns is the teal delivery box. The white helmet is there about 90% of the time, so it is a hint, not a rule.
- 2,581 hand-sorted crops from the detector's own output: 1,637 Checkers and 944 not (other motorbikes, scooters, partial or blurry views, and things that were not bikes at all). I kept one to three crops per pass. If I was unsure, I left it out.
- 2,195 for training, 386 held back to check it. The held-back crops come from the same days as the training ones, and frames from one pass look nearly identical, so the validation score below flatters the model. Splitting by day would have been the stricter test.
- The other bikes were the hard part. Only about 20 bikes a day on this street are not Checkers, against well over a hundred Checkers passes, so collecting enough examples of "not Checkers" took weeks. The set ended up tilted towards Checkers (about 1.7 to 1), which suits the job: missing a real Checkers bike is the costlier mistake.
- Training: 40 epochs at batch size 8, about 15 minutes on the RTX 3050, with RandAugment, horizontal flips and random erasing. The first attempt at batch size 32 ran out of the GPU's 4 GB of memory.
| Called Checkers | Called not Checkers | |
|---|---|---|
| Checkers bike | 245 | 0 |
| Not a Checkers bike | 0 | 141 |
A crop counts as Checkers when it scores 0.65 or more. In practice the threshold hardly matters: the scores are almost all near 0 or near 1. Up to 4 October 2026 it had scored 31,823 raw events as Checkers, 5,913 as not, and only 22 in the uncertain band between 0.50 and 0.65. Those 22 are not counted.
To be honest about it: 100% on crops from the same days it trained on is not 100% in the field. The fairer test is everything since. The model was trained in June 2026 and has scored every crop from then to October. Checking its output against the crops by hand, I find almost no mistakes, but I have not yet run a formal test on a fresh, fully labelled day. The bigger weakness is earlier in the chain: a bike the detector never sees never reaches the classifier.
From detections to passes
One bike usually creates several events, because the tracker loses it for a frame and gives it a new ID. One pass I checked produced tracks 50, 68, 69, 71 and 74. So each evening a script takes the day's Checkers events, sorts them by time, and chains any event that comes within 3 seconds of the one before into the same pass. The event with the highest classifier score becomes that pass's photo and timestamp. Re-running it rebuilds the whole day from scratch, so it is safe to repeat.
Up to 4 October 2026, 37,759 raw motorbike events became 10,219 Checkers passes.
Many riders travel in tandem, two or more bikes close together. When they do, the camera usually gets one detection for the group, and the 3-second merge would fold them into one pass anyway. Every one of those groups is counted as a single bike, which is one more reason the counts are minimums.
Recording the sound
The camera's microphone sends G.711 µ-law audio at 8 kHz, mono: telephone quality. The detector keeps the last few seconds of compressed audio in a rolling buffer. When an event fires, it saves 3 seconds before and 8 seconds after, about 11 seconds in all, and writes the raw bytes straight into a WAV file without re-encoding. I had planned AAC clips, but muxing them with PyAV caused timestamp problems, and writing the WAV directly made those go away. Only one clip records at a time, so each pass gets roughly one clip. Recording started on 27 May 2026.
For each clip I work out the RMS and peak loudness in dBFS over the whole clip. That is relative to the microphone's digital full scale, not a calibrated reading in dB SPL. Loud passes often max out the microphone, so many share the same peak, and the RMS breaks the tie. Treat the sound as a supporting record, not a noise measurement. That is also why the "loudest passes" list came off the evidence page in August 2026 until the recordings are better.
Privacy: blurring the crops
Every crop on the site is edge-blurred to hide the surroundings, and with them the exact location. The middle 20% of the image stays sharp. Outside that, the blur strength grows with distance from the centre (to the power 2.5), up to a Gaussian blur with a sigma of 30 at the corners, blended per pixel from a stack of eight blur levels. The bike stays visible; the street does not. The crops are framed to avoid faces and full number plates in the first place.
Never published: the sharp originals, full video frames, non-Checkers detections, raw events, partly recorded days, and anything about the camera's network. The originals stay private, kept for formal submissions and corrections.
Which days count
A day only reaches the site if it was recorded in full. The rule is simple: every clock hour between 08:00 and 18:00 must contain at least one detection. Daytime traffic on this street never stops for a whole hour, so an empty hour means the detector was down, not that the street was quiet. I can also exclude a day by hand, for example 27 June 2026, when the process hung silently for 107 minutes.
Up to 4 October 2026 the camera recorded 80 days, and 61 passed. The rest stay off the site.
The end of the window moves with the daylight, because the camera is only reliable while there is light. Counting started with an 18:00 cut-off, moved to 18:30 from 18 July 2026 as the days got longer, and to 19:00 from early October 2026. Most of the days recorded so far ended at 18:00. In May and June the share of junk detections after 18:00 more than doubled, which is what set that first cut-off.
The evidence page shows everything the camera saw within each day's own window. Only whole hours count: half an hour, like 18:00 to 18:30, says little about the full hour, so a part-recorded hour is estimated instead. Everything estimated is marked as estimated wherever it appears. As the days get longer, more of the evening is seen and less of it is estimated.
From minimum to estimate
The camera count is a floor. It misses bikes that blur through the stop street, bikes riding in tandem, and the evening after the camera stops. The estimate adds those back with the method published in the seven-week evidence report:
- Evening: 18:00 to 19:00 is taken as 0.65 times that day's 17:00 hour, and 19:00 to 20:00 as 0.35 times. Hours the camera recorded in full are counted instead: on a day the camera ran to 19:00, 18:00 to 19:00 is counted and only 19:00 to 20:00 is estimated. The 0.65 matches the best-recorded evenings (55 to 65%). The 0.35 is an assumption.
- Missed passes: watching the street, the camera misses about one bike in four or five. So the total is multiplied by 1.29 (1.25 to 1.33 as a range). This is a field estimate, not a formal measurement.
For the first seven weeks, that turned 4,216 camera-proven passes (plus 629 estimated evening passes) into about 6,250. Every estimate on the site carries the ≈ sign, with the camera-proven number beside it.
Getting it onto the website
Locally, everything lives in one SQLite database: recording sessions, every event, the passes, daily coverage, and a snapshot of the full configuration for every run. Each snapshot is keyed by a hash of the settings, so any count can be traced back to the exact thresholds that produced it. One evening script runs the classifier, the merge, the coverage summary and the blur in order.
Every three to five days I push new days to Cloudflare:
- Rows to D1. A Python script reads the local database read-only, takes the fully recorded days, and writes SQL that deletes those dates and inserts them again, in chunks of 100 rows to stay under D1's statement size limit. Replacing whole days makes re-running it harmless.
- Media to R2. The blurred crops and the 25 loudest clips per day are pushed to a private R2 bucket through its S3-compatible API with
aws s3 sync. That takes about 4 minutes. Uploading the same files one at a time with Wrangler took about 2 hours.
The site is Astro on a Cloudflare Worker. The evidence page is built from the pass data in D1, and the crops and clips come from the private R2 bucket through the Worker. The daily chart is Chart.js; the by-hour tally is plain HTML.
What did not work
- Deciding everything live. The first plan voted on each track with several crops and removed duplicates on the fly, on a laptop that was already straining. Classifying and merging in the evening is simpler, and easy to re-run when a setting changes.
- Direction gates. I wanted to know which way each bike went. At 15 frames a second, fast bikes jump straight past the gates in one frame, so direction is almost always unknown.
- Counting until 20:00 in winter. After dark the detector mostly found junk. The cut-off at 18:00 now lives in the configuration, so re-running old days cannot bring the junk back.
- Assuming the camera sent AAC. It sends µ-law, which is why the clips are WAV.
Known limits
- One camera, one point on one street.
- Daylight only. Nothing after the last full hour on camera (18:00 or 19:00 so far, depending on the season) is camera-proven.
- Riders speeding through the stop street smear past the camera, and quite a few are missed.
- Bikes riding in tandem are counted as one.
- The sound is uncalibrated and telephone quality.
- The laptop is not a dedicated server, so some days are lost.
Every one of those pushes the count down, never up. Questions about the setup are welcome at info@noisepollution.co.za.