DEV Community

Dimitri Rudenko
Dimitri Rudenko

Posted on Edited on

Counting people at a bus door: why 98% accuracy is an engineering problem, not a sensor spec

Disclosure: I work at ACOREL, a French company that builds automatic passenger counting (APC) systems for public transport. This post is about the engineering problem, not about our products.
A bus door is about a metre wide. People go in, people go out. Counting them sounds like a weekend project with a camera and an object detector.
Then you look at what the numbers are used for. Transit agencies plan timetables with them. In integrated fare systems, operators split ticket revenue based on how many passengers each one carried. Public service contracts pay per passenger-kilometre. At that point, 95% accuracy isn't "pretty good". It's a 5% error in somebody's revenue.
Getting from 95% to 98% is not about buying a better sensor. It's about the whole chain: geometry, tracking, edge processing, validation and data integration. Here's how that chain works.

1. Why the doorway is a hard place to count

A doorway is a narrow, crowded, constantly changing scene. A few things that break naive counting:

  • People stop. Someone stands in the door to let others out, or to check the route number. A track that lingers can be counted twice, or not at all.
  • People overlap. In rush hour, two or three people move through at once, shoulder to shoulder. From above they can merge into one blob.
  • Not everything that moves is a passenger. Prams, suitcases, wheelchairs, bikes, dogs, a backpack held at chest height.
  • Children walk close to adults. Small, close together, sometimes carried.
  • People change their mind. One step in, one step back out. Net zero, but two line crossings.
  • The environment changes. Sunlight through the door, darkness at night, rain, reflective floors, vibration while the vehicle moves.

Each of these produces noise in the raw signal. Most of the engineering is about removing that noise without removing real passengers.

2. Why depth beats a normal camera

A regular 2D camera pointed at a doorway sees colours and shapes. It struggles with shadows, sudden light changes and people who overlap. It also captures faces, which creates a privacy problem before you've written a single line of code.
Most modern APC systems use 3D sensors mounted above the door, looking straight down. From above, people rarely hide each other, and every point in the image has a height. The two main approaches:

The key advantage of depth is simple: a head is a local maximum in a height map. That one property makes it much easier to separate two people walking side by side, ignore a suitcase on the floor, or tell a pram from the adult pushing it.

3. The counting logic: detect, track, cross a line

Once you have a depth map, the pipeline usually looks like this:

  1. Detect candidate heads: local height maxima above a minimum height, with a plausible size.
  2. Track each candidate across frames, so the same person keeps the same ID.
  3. Count when a track crosses a virtual line in the doorway, in a given direction.
  4. Gate counting with the door state: count only while the door is open.
  5. A simplified version of the line-crossing step:
# Simplified illustration, not production code.
# Each track is a sequence of (x, y) positions of one detected head.
# The counting line is y = LINE_Y; inside the vehicle is y > LINE_Y.

LINE_Y = 0.0
MIN_TRAVEL = 0.25  # metres travelled past the line before we trust a crossing

def count_track(positions):
    start_y = positions[0][1]
    end_y = positions[-1][1]

    # Ignore tracks that never really crossed: someone who stepped
    # in and back out, or stood in the doorway.
    if abs(end_y - LINE_Y) < MIN_TRAVEL or abs(start_y - LINE_Y) < MIN_TRAVEL:
        return 0, 0

    if start_y < LINE_Y < end_y:
        return 1, 0   # boarding
    if start_y > LINE_Y > end_y:
        return 0, 1   # alighting
    return 0, 0
Enter fullscreen mode Exit fullscreen mode

The hard part isn't this function. It's everything that feeds it: keeping IDs stable when two people merge and split, deciding whether a small height maximum is a child or a bag, and handling a track that starts or ends outside the field of view. Counting on the net movement of a track, not on every crossing, is what stops one indecisive passenger from becoming two.

4. Privacy by design: process at the edge, export only counts


This is the part I find most interesting from an architecture point of view.
In Europe, anything that can identify a person falls under GDPR. You could collect images, send them to a server and anonymise them there. But then you've built a system that handles personal data at every step: storage, transfer, access control, retention, breach risk.
The alternative is to make sure personal data never exists outside the sensor:

[3D sensor] -> depth frames -> [on-device processing] -> events -> [vehicle gateway] -> [back office]
                                  detect / track / count       boardings, alightings,
                                  frames discarded             door ID, timestamp

Enter fullscreen mode Exit fullscreen mode

The result is a system where privacy isn't a policy added later. It's a property of the architecture. That's much easier to defend to a regulator, a city council or a passenger who asks, "Is that a camera?"
The trade-off is real, though. If you don't keep frames, you can't go back and re-run a better algorithm on last month's data. You have to get the edge processing right, and you need a separate, controlled way to collect reference data for validation.

5. Validation: how do you prove 98%?

"Our sensor is 98% accurate" means nothing without saying how it was measured.
The usual approach is to compare the system's counts with ground truth: manual counting, or counting from a reference video recorded only for the test, under controlled conditions. Then you look at two different things:

  • Per-event error: how far off each door opening is.
  • Systematic error (bias): whether the system over- or under-counts on average across many events.

The distinction matters because errors cancel out. A system that counts +1 at one stop and -1 at the next looks perfect in aggregate. For planning, that may be fine. For revenue, what matters most is that there's no systematic bias: an operator must not be consistently over- or under-paid.

# Simplified bias check over a validation sample
def bias(system_counts, reference_counts):
    total_system = sum(system_counts)
    total_reference = sum(reference_counts)
    return (total_system - total_reference) / total_reference

# +0.008 means the system over-counts by 0.8% on this sample
Enter fullscreen mode Exit fullscreen mode

In Germany, the industry standard VDV 457 formalises this. It defines how to build the sample, how to compare with reference counts, and how to show statistically that the bias stays within a tight limit. Operators across Europe use it as a reference in tenders.
This is also why a lab figure is worth very little. Accuracy has to be shown in your conditions: your vehicles, your doors, your rush hour, your winter coats and prams. A sample that only covers quiet midday trips will flatter any system.

6. From counts to decisions: the data pipeline

A count from one door is not useful yet. Before anyone can plan a timetable or split revenue, the data has to go through several steps:

  • Aggregate doors into vehicle-level boardings and alightings per stop.
  • Match to location and schedule: combine with the vehicle's GPS or AVL data and the timetable (often GTFS) to know which stop, trip and route each event belongs to.
  • Check plausibility: over a full trip, boardings should roughly equal alightings, and the load on board can never be negative.
  • Balance and correct: distribute small imbalances in a documented, consistent way, so corrections don't introduce their own bias.
  • Integrate: send the clean data to the systems that use it: scheduling, ticketing and revenue sharing, network planning, real-time crowding information for passengers.

The plausibility check is a nice example of how a domain constraint catches errors that the sensor alone can't see:

def check_trip(stops):
    # stops: list of (boardings, alightings) in stop order
    load = 0
    for i, (on, off) in enumerate(stops):
        load += on - off
        if load < 0:
            return f"Negative load at stop {i}: check counts or stop matching"
    if abs(load) > 2:
        return f"Trip ends with {load} passengers on board: imbalance to review"
    return "OK"

Enter fullscreen mode Exit fullscreen mode

In my experience, this is where many projects lose value. A good sensor with poor integration produces a nice dashboard that nobody uses. A good sensor with clean, validated data in the scheduling and revenue systems changes real decisions.

Takeaways

  1. Accuracy is a property of the whole system, not of the sensor alone.
  2. Depth sensing from above turns a hard vision problem into a much easier geometry problem.
  3. Processing at the edge and exporting only counts makes privacy a property of the architecture.
  4. Validate against ground truth, in real conditions, and look at bias, not only average error.
  5. The value appears only when counts are matched to the schedule, checked for plausibility and integrated into the systems that make decisions.

If you've worked on people counting, tracking in crowded scenes or edge processing under privacy constraints, I'd like to hear which edge case gave you the most trouble.

Top comments (0)