A foreign-object detector on an EV charging pad was failing in both directions at once. The team wanted to lower the threshold. The threshold was not the problem.
A wireless charging pad has one safety job before it energises: look at its own surface and decide whether anything is lying on it. A coin, a bolt, a bottle cap, a hand. Getting this wrong is asymmetric in an unpleasant way. A missed detection is a safety incident. A false alarm is a charging session that never starts, and enough of those and the product is returned.
This system was doing both. At night, headlight and torch reflections on the glossy pad fired constant alerts. In daylight, a hand covering roughly a third of the pad scored below threshold while a small dark bolt scored near the top of the range.
Those two numbers are the whole story. The model was three times more confident about a bolt than about a human hand. Any fix that moves the threshold has to accept one of those two answers as correct, and both are unacceptable.
I was brought in remotely, and the first useful information arrived as screenshots of the running system. Screenshots carry far more than the picture inside them.
The frames had a distinctive magenta cast. That is the signature of a NoIR sensor — a camera with the infrared cut filter removed, so the red channel receives near-infrared it would normally reject. Combined with the resolution and framing, that identified the camera module. The window chrome around the image was OpenCV's own highgui decoration, which fixed the runtime. And the timestamps moving across successive frames implied a loop running at roughly 1.5 frames per second.
So before reviewing a line of code, the operating picture was: a Raspberry Pi with a Camera Module 3 NoIR (IMX708), a Python stack on OpenCV and Picamera2, inference through OpenVINO, and a control loop slow enough that any detection strategy depending on fast temporal sampling was already off the table.
The team's proposed fix was to lower the detection threshold so the hand would clear it. This is the most common response to a missed detection, and here it would have made the product worse.
A threshold is a single operating point on a curve. Moving it trades one error type for the other. This system was already producing false alarms at night — so lowering the threshold buys the hand at the cost of turning every headlight reflection into a refused charging session. You have not fixed anything; you have chosen which way to fail.
When a detector fails in both directions at once, the operating point is not the problem. The decision architecture is.
That reframing is the entire engagement. Everything after it is mechanical.
Six specific design decisions combined to produce that score inversion. Individually each is defensible. Together they guarantee the failure.
Of the six, per-frame normalisation is the one most worth internalising, because it appears constantly in vision code and looks harmless.
Rescaling each frame's heatmap to span 0–1 means the output no longer says "how anomalous is this frame" — it says "which part of this frame is most anomalous relative to the rest of this frame." An empty pad with faint sensor noise gets stretched until its loudest noise pixel reads 1.0. The comparison you needed — this frame against a known-clear reference — has been thrown away before the model sees it.
The architecture explains the false alarms. It does not by itself explain why a hand scored 0.303. For that you have to look at what the model was taught was normal.
The pad is glossy. Throughout dataset collection it sat there mirroring whatever was above it — car undersides, ceiling lights, the general bright clutter of a workshop. Those reflections are large, soft-edged and bright, and they were present in a large share of the frames labelled as normal.
So the anomaly model learned, correctly and uselessly, that large soft bright shapes are the expected state of this surface.
A human hand over a charging pad is a large, soft-edged, relatively bright shape. It matches the learned background almost perfectly. A 12 mm bolt is small, hard-edged and dark — nothing in the training distribution looks like that, so it scores high.
The model was not broken. It answered the question it was actually asked. The question was wrong, and it was made wrong by the reflectivity of the surface being monitored.
Retraining was possible but slow, and the constraint was to ship something deployable immediately. So the redesign kept the existing model and changed what it was responsible for.
A log-ratio comparison of the current frame against a stored reference frame of the clear pad. No model, no learned features, no opinion about what an object looks like. If a meaningful region of the pad differs substantially in brightness from the reference, something is on it.
This catches large objects — the hand — within two frames, regardless of what the anomaly model thinks. It is nearly impossible to fool with a large object, because a large object cannot fail to change the surface it sits on. And crucially, at 1.5 FPS, two frames is a little over a second.
The model stays, but only for the case where it is genuinely strong: small foreign objects. And its output is now gated by AND-logic across four conditions rather than OR:
Requiring all four to hold means the false-positive rates multiply instead of add. A glint that clears one condition almost never clears all four, because the conditions are close to independent — a reflection that is bright enough usually fails the glare test, and one that survives that usually fails persistence because it moves with the light.
Alongside this, exposure lock became deterministic rather than time-based, and normalisation moved to a fixed reference so absolute brightness survived to the decision.
The last point is the commercially interesting one. The team had assumed the path forward was more data and another training run — weeks of work with an uncertain result. The actual fix was a restructure of how existing signals were combined.
Generalising out of the specifics, this is the order I would work through on any detector that is failing in both directions:
You should, eventually — the dataset problem is real. But retraining does not fix OR-logic, per-frame normalisation or missing persistence, so you would spend weeks and still have a detector that fires on headlights. Fix the architecture first; then retrain against a background class that has been collected deliberately.
It has to be managed — the reference needs refreshing when the scene legitimately changes, and the comparison is done in log-ratio space precisely because that is more stable against global illumination shifts than a raw difference. But an object on the pad changes the surface locally in a way a global lighting change does not.
That figure is false-alarm detection efficiency — the false-positive side, which is what was destroying usability. The miss side was addressed structurally rather than statistically: tier one catches large objects without consulting the model, so the specific failure that produced 0.303 cannot recur through low confidence.
None of the diagnosis. The camera, runtime and loop rate came from screenshots; the architectural causes came from reading the code against those constraints. Hardware access matters for verification, not for finding the fault.
If you have a detector misbehaving in the field, send the evidence — frames, logs, scores. I'll tell you in one paragraph whether the problem is your threshold or your architecture, and whether a scoping week is worth it.
Start there →