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.
The bolt's anomaly score was about three times the hand's. These scores are not calibrated probabilities: 0.921 does not mean a 92.1% chance of an object. The ordering exposed a problem with using this model score as the only route to detect the large hand.
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 conditions makes the accepted set narrower than accepting any single indicator. The actual false-positive change depends on how the conditions interact. Their rates cannot simply be multiplied unless independence is established; size, brightness, glare and persistence may be correlated. The combined decision needs evaluation on representative frames.
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.
“False-alarm detection efficiency 98%” is the result reported for this engagement. The public record does not include the denominator, test-set composition or a complete confusion matrix. It should not be read as overall accuracy, 98% fewer false alarms, a miss rate or a safety certification. Those are different measures and cannot be reconstructed from the published figure.
For a new detector, define the evaluation before comparing a repair. Record which events count as true objects, which count as false alarms, and whether the unit is a frame, an object encounter or a charging session. A long sequence of frames from one reflection should not silently become thousands of independent test examples.
Google's classification metrics guide distinguishes precision and recall: how many positive predictions are correct, and how many actual positives are found. For this project, the published 98% label cannot be mapped to either without more evaluation data.
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.
The public 98% label alone cannot establish safety. Its denominator and full evaluation set are not published. The repair removed dependence on model confidence for the large-object path, but the complete system still needs validation against its hazards and operating conditions.
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 →