byASB Ankiit Singh Book a scoping week
← Writing/Computer vision
TEARDOWNEV wireless charging · 2026

The model scored a hand 0.303 and a bolt 0.921

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.

Frame 0417 · a human hand 0.303 Verdict — CLEAR ✗ (wrong)
Frame 1902 · a 12 mm bolt 0.921 Verdict — FOD ✓

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.

Reading the hardware before reading the code

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.

Diagnosing from evidence first is not a party trick. It sets the constraints the fix has to live inside — in this case, a 1.5 FPS loop and a sensor that sees infrared.

Why the threshold was the wrong lever

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.

The six architectural causes

Six specific design decisions combined to produce that score inversion. Individually each is defensible. Together they guarantee the failure.

OR-based decision logicAny single indicator firing was enough to declare an object. With several noisy indicators, the false-positive rates add rather than intersect. Noise wins.
7–10 px minimum blobA fixed minimum size, applied per frame, with nothing requiring the blob to persist. Sensor noise and specular glints routinely exceed a 7 px threshold for one frame.
Auto-exposure lock at 1 sExposure locked one second after start, so the locked value depended on whatever the scene happened to be at that moment. Two boots under different light produced two different detectors.
No spatial persistenceNothing checked that a detection appeared in the same place across frames. A real object stays put; a reflection moves with the light source.
Per-frame heatmap normalisationMin-max rescaling per frame destroys absolute brightness between frames — a quiet frame is stretched until its noise looks exactly like a real anomaly.
CLAHE on the inputLocal contrast equalisation improves human-visible detail and removes the global brightness cue the detector actually needed. It was flattening the signal.

The normalisation problem, specifically

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.

Why the model learned the wrong thing

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.

This is the failure mode to watch for whenever you train an anomaly model on a reflective surface: your background class quietly absorbs the very features your dangerous objects share.

The fix: two tiers, different jobs

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.

Tier one — model-free, for large objects

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.

Tier two — the anomaly model, narrowed

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:

  • Size — the region must be large enough to matter
  • Peak — the anomaly response must be strong, not marginal
  • Not glare — the region must not match the signature of a specular reflection
  • Persistence — it must stay in the same place across frames

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 outcome

  • False-alarm detection efficiency reached 98%.
  • Misses were eliminated by design, not by tuning — tier one does not depend on model confidence, so the hand case cannot recur through a scoring failure.
  • Deployable the same night. No retraining cycle, no new dataset collection, no hardware change.

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.

If your detector is doing this, check these first

Generalising out of the specifics, this is the order I would work through on any detector that is failing in both directions:

  1. Is it failing both ways at once? If yes, stop tuning the threshold. You have an architecture problem and the threshold is a distraction.
  2. Is your combination logic OR or AND? OR across noisy indicators sums their false-positive rates. AND across near-independent conditions multiplies them downward.
  3. Is anything normalised per frame? If so, you have discarded between-frame comparison. Normalise against a fixed reference instead.
  4. Does a detection have to persist? Real objects stay put. Noise and reflections do not. Persistence is the cheapest false-positive filter available and it costs one frame of latency.
  5. What does your background class actually contain? If the monitored surface is reflective, glossy or transparent, assume your normal class has absorbed features your target objects share.
  6. Can a model-free check cover the easy cases? Large, obvious, high-consequence detections should not depend on a learned model's confidence. Give them their own deterministic path.

Questions I get about this one

Why not just retrain on a better dataset?

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.

Doesn't a reference-frame comparison break when lighting changes?

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.

Is 98% good enough for a safety system?

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.

How much of this needed access to the hardware?

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 this sounds familiar

Send me the screenshot of it failing.

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 →
Engagement details are published with the client unnamed. Every figure here — the 0.303 and 0.921 scores, the 7–10 px minimum blob, the 1 s exposure lock, the 98% result — comes from the real engagement. The generalised checklist is my own practice, not the client's.

Related: the project page for this engagement · all work