Sitemap

Spotting Bull Bars with a Pocket-Sized Brain

13 min readJust now

--

Press enter or click to view image in full size

How we taught MobileNetV2 to answer one question about every car on the road, and why better data beat a bigger network

PPicture a traffic camera on a busy Adelaide road. Every few seconds a vehicle rolls past: a hatchback, a delivery van, a shiny dual-cab ute. Some of those utes carry a bull bar, a heavy steel or alloy frame bolted across the front.

Out in the country, a bull bar is a sensible thing to own. It protects the radiator when a kangaroo jumps out at dusk. In the city, it is a different story. A rigid bar sits right at the height of a person’s legs and hips, and road-safety researchers have long been worried about what that means for pedestrians in a crash.

To study that question you first need a very simple number: how many vehicles on this road actually have a bull bar? Today, someone has to sit and count by hand, video after video. Our capstone team set out to let a computer do the counting.

This article is about the small neural network at the heart of that tool, MobileNetV2: how it works, why we chose it, and the surprising lesson we learned while training it.

Press enter or click to view image in full size
Figure 1. The whole journey. A tracker finds each vehicle, we cut out a photo of its front, and MobileNetV2 decides “bull bar” or “no bull bar”.

The job, in one sentence

Our app reads a video, finds every vehicle with a YOLO11 tracker, and gives each one an ID so it is only counted once. For every vehicle that drives toward the camera (a bull bar is on the front, so cars driving away are no use), it cuts out one square photo of the vehicle.

Then comes the part this article is about. A classifier looks at that photo and answers one yes-or-no question:

Does this vehicle have a bull bar?

That is a classic image classification problem with two classes. We needed a model that was accurate, fast enough to keep up with hours of video on an ordinary laptop, and trainable on the small amount of labeled data we had. MobileNetV2 ticked all three boxes.

Why a “small” network?

You don’t hire a jumbo jet to deliver a pizza. Big image networks like ResNet-50 carry about 25 million learned numbers (called weights). They are excellent, but heavy.

MobileNetV2 was designed by Google researchers in 2018 to run on mobile phones. Its body has only about 2.2 million weights, roughly a tenth of ResNet-50, and it still reaches about 72% top-1 accuracy on ImageNet, the famous 1,000-class photo benchmark.

It gets there with two clever ideas. Let’s take them one at a time.

Idea 1: split one big job into two small ones

A normal convolution layer slides small filters over an image. Each filter looks at a 3×3 patch of neighbouring pixels and at every channel (colour or feature map) at the same time. That is powerful, but it costs a lot.

Think of it like a kitchen making a fruit smoothie. In a standard convolution, every chef handles every fruit at once. MobileNet splits the work:

  1. Depthwise step. Each fruit gets its own chef, who only chops that one fruit. In network terms: one small 3×3 filter per channel, looking at neighbours but never mixing channels.
  2. Pointwise step. A blender mixes everything together. In network terms: a 1×1 filter that blends the channels, without looking at neighbours at all.
Press enter or click to view image in full size
Figure 2. For a layer that turns 64 channels into 128, the split version needs 8,768 weights instead of 73,728. That is 8.4 times fewer, with almost the same power.

This trick is called a depthwise separable convolution, and it is the reason MobileNets are so light.

Idea 2: the inverted residual block

The second idea is what makes version 2 special. Most modern networks use residual blocks: a few layers plus a shortcut (a “skip connection”) that lets the input jump over them and get added back at the end. The shortcut makes deep networks much easier to train.

In a classic ResNet block, the data goes wide → narrow → wide. MobileNetV2 flips it to narrow → wide → narrow, which is why it is called an inverted residual.

A good way to picture it is a suitcase:

  • You arrive with a small, packed suitcase (a narrow layer with, say, 24 channels).
  • You unpack it onto a big table (a 1×1 layer expands it 6 times, to 144 channels).
  • You work on each item (the cheap 3×3 depthwise filter does the real looking-around).
  • You pack it back into the small suitcase (a 1×1 layer squeezes it back to 24).

The shortcut only has to carry the small suitcase, which saves a lot of memory.

Press enter or click to view image in full size
Figure 3. Left: the classic ResNet block. Right: MobileNetV2’s inverted version, where the expensive work happens in the wide middle using cheap depthwise filters.

There is one more subtle detail, called the linear bottleneck. Most layers end with an activation called ReLU, which turns every negative number into zero. In a wide layer that is fine, because there is lots of room for the information. In a narrow layer, chopping off half the values throws away information you can’t get back, a bit like folding a map so tightly that the creases wipe out street names. So the last 1×1 layer in each block has no activation at all. It stays “linear”.

(The activation the other layers use is ReLU6, a ReLU capped at 6. The cap keeps numbers in a small range, which suits the low-precision maths on phone chips.)

The whole network, layer by layer

Stack 17 of those blocks together and you have MobileNetV2. Here is what happens to one of our 224×224 vehicle photos as it travels through:

Press enter or click to view image in full size
Figure 4. The picture gets smaller (224 → 7 pixels across) while the number of feature channels grows (3 → 1,280). Each “÷2” halves the width and height.

Reading it left to right:

  • Input: a 224 × 224 colour image (3 channels: red, green, blue).
  • First convolution: a normal 3×3 convolution with 32 filters, which also halves the size to 112 × 112.
  • Seven groups of bottleneck blocks: 17 inverted residual blocks in total. Each group is described by three numbers: t (how much the block widens inside, usually 6), n (how many blocks in a row) and s (whether the first block halves the size). Slowly, the image shrinks to 7 × 7 while the channels grow to 320.
  • Final 1×1 convolution: widens to 1,280 channels. At this point the network is no longer holding “pixels”; it is holding 1,280 learned descriptions of each region of the vehicle.
  • Average pooling: averages each of those 1,280 maps down to a single number.
  • The head: a final layer that turns 1,280 numbers into an answer. This is the only part we changed.

Transfer learning: keep the eyes, swap the brain

Here is the honest truth about our project: when we started, we had 6 labeled photos of vehicles with bull bars. Six. No pretrained bull-bar model exists anywhere for us to download.

Training 2.2 million weights from zero on six examples is hopeless. So we used transfer learning.

Imagine you need someone to spot bull bars in photos. You could raise a newborn baby and teach them to see from scratch. Or you could hire an experienced photographer, who already understands edges, shadows, wheels and grilles, and simply teach them one new thing. Transfer learning is hiring the photographer.

The version of MobileNetV2 we download has already learned from 1.28 million ImageNet photos in 1,000 classes. Its early layers detect edges and colours, the middle layers detect textures and shapes, and the late layers respond to object parts. All of that is useful for spotting a steel bar on the front of a ute.

Press enter or click to view image in full size
Figure 5. We keep the pretrained body and throw away its 1,000-class head. The new head has just 2,562 weights and two outputs.

In PyTorch, the whole idea fits in three lines. This is the actual function from our code:

from torch import nn
from torchvision import models

def build_model() -> nn.Module:
model = models.mobilenet_v2(weights=models.MobileNet_V2_Weights.DEFAULT)
model.classifier[1] = nn.Linear(model.last_channel, 2)
return model

The first line downloads MobileNetV2 with its ImageNet knowledge. The second swaps the last layer (1,280 → 1,000) for a new one (1,280 → 2). We then train the whole network gently, with the Adam optimiser at a small learning rate of 0.0001, so the pretrained knowledge gets nudged, not wiped out.

Training on lopsided data

Bull bars are rare. In our early data, only about 6 in every 100 vehicle photos had one. That creates a nasty trap.

Get Buddhi Prabodhika Karunarathna’s stories in your inbox

Join Medium for free to get updates from this writer.

A model that simply answers “no bull bar” to every photo scores 94% accuracy and catches exactly zero bull bars. This really happened to us: one early training run collapsed into saying “no” to everything, and never recovered.

Press enter or click to view image in full size
Figure 6. Six habits that stop the model from taking the lazy way out.

Here is what each habit does, in plain words:

  1. Split by vehicle. The tracker often saves several photos of the same car. If one lands in training and another in testing, the test is just a memory quiz. We group photos by vehicle ID so a car only ever lives in one set.
  2. Balanced batches. The model learns in small batches of 8 photos. With random shuffling, most batches contained no bull bar at all, so there was nothing to learn from. A weighted random sampler draws bull-bar photos more often, so they appear in most batches.
  3. Gentle class weights. We tell the loss function that missing a bull bar costs more than a false alarm. The full imbalance (about 7.7×) made the model jumpy, so we use its square root (about 2.8×).
  4. Realistic augmentation. Each training photo is randomly mirrored, made a bit brighter or darker, and tilted by up to 8 degrees. These match how real traffic footage varies. We avoid big rotations, because cars don’t drive upside down.
  5. Small, careful steps. A learning rate of 0.0001, halved whenever validation results stop improving.
  6. Judge by F1, not accuracy. F1 is a score that only goes up when the model both finds bull bars (recall) and is right when it says “bull bar” (precision). We keep the training checkpoint with the best F1 and stop early after 5 epochs with no improvement.

The two most important lines look like this:

# show bull bars in most batches, even though they are rare
sampler = WeightedRandomSampler(sample_weights, num_samples=len(train_rows), replacement=True)

# missing a bull bar costs more, but only by the square root of the imbalance
class_weight = torch.tensor(
[(train_size / (2 * train_counts[label])) ** 0.5 for label in LABEL_TO_IDX]
)
criterion = nn.CrossEntropyLoss(weight=class_weight)

The plot twist: the network was never the problem

A good recipe still needs ingredients, and labeling is slow. One hour of road video holds hundreds of vehicles. Looking at every photo from scratch and deciding “bull bar or not” would take forever, and most of the photos are ordinary cars anyway.

So we put the model to work on its own training data. The model does the first pass, and a person makes the final call. In machine learning this is called model-assisted labeling (or human-in-the-loop labeling). It is close to an idea called pseudo-labeling, where a model’s own guesses become new labels, except that here a person checks every guess before it is trusted.

Press enter or click to view image in full size
Figure 7. One lap of the loop. The machine does the boring, fast part; people do the part that needs judgement.

Here is one lap, step by step:

  1. Extract the crops. Our crop extractor runs through a whole folder of video and saves one square photo of each vehicle driving toward the camera. It only runs the detector when something is moving, so one hour of video takes about 95 seconds.
  2. The model pre-labels them. The current bull-bar model scores every crop and sorts it into two folders: likely bull bar (a score of 0.79 or higher) and other.
  3. A person reviews them. Using our labeling tool, a person goes through the likely bull bar folder and confirms or corrects each one with a single key press (Y for yes, N for no, U for unsure). They also check a random sample of the other folder, to catch the bull bars the model missed. The model’s scores are hidden while labeling, so they can’t bias the person, and unsure photos are left out of the data.
  4. Test first. Before training on the new labels, we score the old model on them. A day of video it has never seen is the fairest exam we can give it.
  5. Retrain. The checked labels are merged into the dataset, and a new, sharper model is trained. That model does the pre-labeling on the next lap.

Why does this work so well? Because checking is much faster than labeling from scratch, and because the model’s wrong guesses are exactly the photos it most needs to learn from. When the model says “bull bar” with confidence and a person says “no, that’s just a dark grille”, that correction is worth more than a hundred easy examples.

The random sample of the other folder matters just as much. Without it, we would only ever see the bull bars the model already finds. Those samples showed that between about 1.5% and 8% of the “other” photos were real bull bars, depending on the road and camera angle, and each one went back into training.

The plot twist: the network was never the problem

We trained the model over twelve rounds. Here is the part we did not expect: we never changed the network once. Every round used the same MobileNetV2. What changed was the data.

Press enter or click to view image in full size
Figure 8. Test F1 for the bull-bar class across twelve rounds. The big jump at round 7 came from labeling the model’s own mistakes, not from a new model.
  • Rounds 1 to 6: with 6 to 40 real bull bars, results swung wildly. One round scored 0.67, the next collapsed to zero. Fixing the sampler and switching to F1 helped, but the model was starving for examples.
  • Round 7, the turning point, and our first full lap of the labeling loop: the model pre-labeled new videos and put 464 photos at a score of 0.9 or higher. People checked them all. Only 227 really had a bull bar. The other 231 were plain utes, 4WDs, vans and dark front ends. We kept all of them, mistakes included, and retrained. On unseen videos, false alarms dropped from 231 to 20 while the model still caught 96% of real bull bars.
  • Rounds 8 to 12: a new day, a different road and a side-on camera angle. Each round was another lap of the same loop, and the training set grew to 686 real bull bars.

Hard negatives (things that look like the answer but aren’t) taught our model more than any change to its architecture could have.

Picking the cutoff

The model doesn’t really say “yes” or “no”. It gives a probability, for example P(bull bar) = 0.93. We then pick a cutoff: any vehicle at or above it gets flagged.

Where you put that cutoff is a trade-off between catching every bull bar and avoiding false alarms:

Press enter or click to view image in full size
Figure 9. Raising the cutoff from 0.5 to 0.9 means fewer false alarms, but more missed bull bars.

We chose 0.79. On our validation and test photos, it catches 89% of bull bars with false alarms on only 1.7% of normal cars, and 96% of its alerts are real. The cutoff lives in a config file, so it can be changed without touching code:

bullbar:
weights: models/bullbar/bullbar_classifier_traffic_cam_only.pt
threshold: 0.79

One warning we learned the hard way: the right cutoff depends on the camera. On a side-on camera at Seaview Road, scores ran lower. At 0.79 the model found only 32% of bull bars, while at 0.3 it found 76%. The model was still ranking photos well (AUC 0.96). It was simply less confident from that angle. Choose the cutoff for each camera, using labeled photos from that camera.

The final result

Here is how the current model does on its held-out test set, at the plain 0.5 cutoff:

  • 100 of 105 bull bars caught
  • 4 false alarms out of 210 normal vehicles
  • F1 score: 0.96

At inference time, the whole thing is a few lines. Each vehicle photo is resized to 224 × 224, normalised the same way ImageNet photos were, and passed through the network:

tensor = self.transform(Image.fromarray(rgb)).unsqueeze(0).to(self.device)
with torch.no_grad():
probs = torch.softmax(self.model(tensor), dim=1)[0]
p_present = float(probs[LABEL_TO_IDX["present"]])
label = "present" if p_present >= self.threshold else "absent"

What we would tell our past selves

  • Start with transfer learning. With a few dozen examples, a pretrained MobileNetV2 is the difference between “impossible” and “working”.
  • Accuracy lies on lopsided data. Watch F1 for the rare class instead.
  • Label the model’s mistakes. The confident wrong answers are the most valuable photos you have.
  • Always label a random sample too. Otherwise you only ever see what the model already finds.
  • Keep your test honest. Split by vehicle, never train on the test set, and compare models on the same photos.
  • Fix the data before the model. We never needed a bigger network.

Honest limits

Night, rain and other camera positions are still untested. The test photos also contain far more bull bars than real traffic does. And resolution matters: very small, far-away vehicles simply don’t have enough pixels for anyone, human or machine, to see a bar.

Wrapping up

MobileNetV2 is a lovely piece of engineering. Splitting convolutions into two cheap steps, and turning the residual block inside out, gives a network small enough for a phone and smart enough to learn from a few hundred examples.

But the real lesson of our project was not about layers at all. A small network, fed with carefully labeled data, including every embarrassing mistake it made, ended up doing a job that used to take hours of human counting.

Sometimes the best upgrade isn’t a bigger brain. It’s a better teacher.

References: Sandler et al., “MobileNetV2: Inverted Residuals and Linear Bottlenecks”, CVPR 2018. Howard et al., “MobileNets: Efficient Convolutional Neural Networks for Mobile Vision Applications”, 2017. PyTorch torchvision mobilenet_v2.

--

--

Buddhi Prabodhika Karunarathna
Buddhi Prabodhika Karunarathna

Pursuing a Master’s in AI & ML at the University of Adelaide, building on 7+ years of software engineering with curiosity and passion for innovation.