field note · 03

Teaching the eyes to ignore a box fan.

A $20 box fan fooled both of Meatball's senses at once — it hissed on the mic and it waved at the camera, and the camera kept reporting "someone's here." The fix is a small lesson in where the leverage actually is: never on the picture you can see, always upstream of it or in the rule that reads it.

the motion pipeline, after the fixlock the camera → grab → diff vs. last frame → per-cell adaptive gate → blob → trigger
01

One fan, two fooled senses

Meatball watches for motion the obvious way: every ~10 seconds it grabs a frame from each camera, subtracts the previous one, and scores how much changed across a 32×18 grid. A bright region in the difference means something moved. Simple, cheap, and — with a box fan in the room — wrong constantly. The blades are alwaysin a new position, so the fan's corner of the frame lights up every single tick. To a plain frame-differ, a spinning fan and a walking person are the same event.

The same fan had already shown up on the other sense. When we re-measured the microphones, one mic's noise floor had jumped 24 dB— from −41 dBFS to −17 — and its gain was pinned at the maximum, 255. That was the fan's draft and hum, and a gained-up sensor amplifying it. One cheap appliance, both senses saturated.

the noise floor that gave it awaybrio mic floor −41.1 dBFS spread 0.9 dB quiet, stable quickcam mic floor −17.3 dBFS spread 4.0 dB ← +24 dB: the fan quickcam gain 255 / 255 maxed → amplifying its own room noise
02

Why you can't just turn up the contrast

The first instinct is to stretch the difference image — "the motion's faint, boost it." It doesn't help, and it's worth knowing exactly why. The difference is dark because most of the frame genuinely didn'tchange — that's correct. Multiply it and you scale the real motion andthe sensor speckle and the lighting residual by the same factor. The signal-to-noise ratio is unchanged; you've just moved the numbers around and made the threshold harder to place.

All the leverage is somewhere else: upstream of the subtraction (stop the bad change from entering the frame) or in the decision rule (be smarter about which changes count). Never on the delta itself. So we did both.

03

Two different lies need two different fixes

The false triggers came from two genuinely different causes, and no single trick fixes both — which is exactly why naïve approaches flail.

the two failure modescause nature fix ───────────────────────── ──────────────────────────── ─────────────────────── auto-exposure / white-bal photometric — the scene lock it at the source re-metering (a light, a didn't move, the camera (camera controls) bright shirt walks in) re-mapped brightness ───────────────────────── ──────────────────────────── ─────────────────────── the fan, the monitors real pixel change, just a smarter decision rule not the change you care about (adaptive per-cell gate)

No brightness trick fixes a re-metering camera; no camera setting fixes a fan. You want one of each.

04

Fix one — kill it at the source: lock the camera

A webcam left on auto re-meters constantly. Auto-exposure rebalances when a screen flickers or something bright enters frame; auto-white-balance shifts the whole color map; continuous autofocus hunts. Every one of those is a whole-frame change you then have to fight in software. The cameras expose a whole galaxy of these knobs over v4l2 — 29 of them across the two we run — and the cure is to stop the automatic ones from moving.

the stable profile — pin the autos, freeze the pictureauto_exposure → Manual (was: Aperture-Priority) white_balance_automatic → off exposure_dynamic_framerate → off focus_automatic_continuous → off then PIN exposure / white-balance / focus / gain to whatever auto had just chosen — so the picture doesn't visibly jump, it just stops hunting.

The trick in that last line matters: we read what auto-exposure currently landed on and write it back as the fixed manual value. The image looks identical the instant before and after — the only thing that changes is that it stops chasing the room. The doc that started this called locking the camera "the biggest single win, zero compute," and it's right.

05

Fix two — let every cell set its own bar

The fan is real motion, so no camera setting touches it. The decision rule has to get smarter. Instead of one global threshold for the whole frame, every cell of the 32×18 grid keeps a running model of its own history — an exponential mean and variance of how much that spot normally changes.

# per grid cell, each tick: resid = delta − mean # how surprising is this? fires = delta > mean + K·σ (K = 3.5) # ...vs. this cell's OWN noise mean = (1−α)·mean + α·delta (α = 0.15) # then learn, slowly var = (1−α)·var + α·resid²

This is the whole idea: a cell only counts as motion when it beats its ownlearned noise floor, not a global one. The fan's cells see big changes every tick, so their mean and variance climb until the fan no longer surprises them — the fan raises its own bar and self-mutes. The monitors do the same. A slow drift in room light gets absorbed into each cell's mean for free. And a person crossing a normally still patch of floor blows past that patch's tiny variance and fires immediately. Same math that gave the mics an adaptive noise floor, now per pixel.

06

Fix two-and-a-half — require a real blob

One more cheap filter. Even with the adaptive gate, a stray cell occasionally flickers through. A person isn't one hot cell — they're a contiguous, body-sized region. So we label the connected components of the fired cells and keep only the largest run (≥ 3 cells), and that becomes the box we hand to the vision model. Scattered single-cell blips — the last gasps of the fan — get dropped; the person-shaped blob wins. It also stops a sudden whole-frame light change from drawing one enormous box: if more than half the grid fires at once, that's not a person, that's the lights, and we ignore it.

07

The payoff

Tested first on a synthetic scene — a busy "fan" block plus a "person" crossing a quiet area — then live on the real rig. The fan stopped existing as far as the trigger was concerned, and the person came through clean.

before the gate learned, vs. aftersynthetic, fan only → 0 false fires once warmed (~1 min) synthetic, person+fan → one tight box on the PERSON, fan excluded live, raw delta spike 14 → old code: FIRES (threshold was 7) new code: gated motion 0, no box ✓

That last line is the one I like. A global auto-exposure blip pushed the raw difference to 14 — well over the old fixed threshold of 7, a guaranteed false alarm in the old world. The new pipeline looked at it cell by cell, saw nothing beat its own bar, and stayed quiet. The fan spins, the lights flicker, the screens scroll — and Meatball only speaks up when something that isn't supposed to move, moves.

The lesson generalizes past one appliance: when a sensor lies, don't amplify the lie. Stop it at the source, or teach the part that reads it to know what normal looks like. Usually both.