A face-recognition gate I built rejects printed photos, screen replays, and looped video. Last night I found out it was doing it for the wrong reason, and the wrong reason looks identical to the right one from the outside.
Here is the whole thing, measured.
Two channels.
Identity: ArcFace embedding from InsightFace buffalo_l, cosine against a gallery of real faces, threshold 0.45.
Liveness: the operator says "go", and the person flashes their phone flashlight across their face. The gate measures alternation in the red-blue difference of the center crop: the share of consecutive frames where R - B moves by more than 20. A strobe is present after the prompt and absent before it. A recording of a past strobe session is already running when the prompt lands, so its pre-window is never quiet.
A printed photo on a table, filmed while a phone played a flashing app at it. Full-frame R-B range across the clip: 120.6. A real strobe, from a real recording.
The gate's alternation channel reported 0.0. Twice: 0.0 before the prompt, 0.0 after.
It rejected the attack. I almost shipped that number.
The camera recorded at 20 fps. The phone flashlight pulses around 10 Hz. My liveness channel sampled the video at 10 Hz, because 10 Hz was enough for the blink channel and I had reused the same frame stride.
Sampling a 10 Hz square wave at 10 Hz does not capture it. Depending on phase it reads a nearly constant line: every sample lands on the same half of the cycle. I had built a channel that was blind to exactly the signal it existed to find, and the blindness arrived as a clean, confident zero.
So two different situations produced the same verdict:
Both print REJECT. Only one of them says anything about the attacker. If a real human had flashed a real phone at the camera, the same channel would have missed it and false-rejected them. A gate that rejects everything is not secure. It is just closed.
Alternation is now computed at the clip's native frame rate. Same clip, same code, one number changed:
| sampling | pre-prompt alternation | post-prompt alternation |
|---|---|---|
| 10 Hz stride | 0.0 | 0.0 |
| native 20 fps | 0.655 | 1.000 |
And the property the design actually needs is not "the loop scores high", it is "no prompt time lets the loop through". I swept it:
| attack | prompt times swept | prompt times where pre is quiet and post is present |
|---|---|---|
| printed photo held to camera | 19 | 0 |
| screen replay of a selfie video | 28 | 0 |
| looped flash session | 14 | 0 |
The loop's pre-prompt alternation never falls below 0.62 at any of the 14 prompt times I tried. There is no "go" I could accidentally pick that hands it a quiet window. It dies by the pre-window check, which is the check that is supposed to kill it.
Identity was on the same 10 Hz stride. Decision window is 1.5 s, so that is 3 samples, and if the lit frames fell between samples the lit-frame mask came back empty and identity returned None. On one clip that surfaced as a straight false reject.
Now identity runs at native rate too: 30 to 45 samples per decision window, and the report prints the window's mean cosine, the whole-clip mean, and the pre-prompt mean separately, so a drag in any one of them is visible instead of averaged away.
Every rejection report now has to name the check that fired, not just the verdict. identity_below_threshold and no_strobe_after_prompt and strobe_already_running are three different findings, and only the third one is a liveness result.
The general form: a negative is not a measurement until you can say what would have produced a positive. If I cannot describe the sample that would pass, I have not built a detector, I have built a closed door.
The code is a Python script, two OpenCV series, one InsightFace model, and a report that prints why.