The Bouncer at My Gallery's Door Is One Line: cv2.Laplacian().var()
Last post said enrollment now rejects blurry photos. This one is about how 'blurry' gets measured. The Laplacian variance I found by asking Gemini: a second derivative to catch edges, a variance to see how sharp they are. One line of code, keeping motion-blurred and dirty-lens junk frames out of the feature gallery.
The Bouncer at My Gallery’s Door Is One Line: cv2.Laplacian().var()
In the last post — the one where my flatmate got recognized as me — I said one of the fixes was “enrollment now rejects blurry photos outright.” I let that slide by in a single sentence, but there’s a concrete problem hiding inside it:
how does a program know whether a photo is blurry?
A human can tell at a glance. There is no at_a_glance() in code.
How I Found It
At first I didn’t even know what this class of techniques was called. Searching “how to tell if an image is sharp” got me photo-editing tutorials.
So I changed tactics and just asked Gemini: is there a technique for assessing image sharpness?
It gave me a few directions, and the most classic, most widely used one was the Variance of Laplacian — run a Laplacian transform over the image, take the variance of the result, and that single number is your sharpness score. Once I had the term, the rest was easy: following the keywords led me to OpenCV’s own article on edge detection (Edge Detection Using OpenCV), and I worked through how the Laplacian operator actually behaves.
A side lesson worth keeping: when you don’t know what something is called, asking an AI “is there a technique that does X” beats a search engine by a mile. Its value isn’t the answer — it’s the terminology. Once you have the term, the docs read themselves.
How It Works
The whole algorithm is one line. Unrolled, it’s three steps:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
lap = cv2.Laplacian(gray, cv2.CV_64F)
score = lap.var()
Step one: grayscale. Sharpness is entirely about brightness changes; color contributes nothing, so three channels collapse into one.
Step two: the Laplacian operator. It’s the image’s second derivative. The first derivative (the gradient) measures how fast brightness changes; the second derivative measures how the rate of change itself changes — which makes it maximally sensitive to abrupt transitions. And abrupt transitions in an image are edges: eyelashes, strands of hair, skin texture, the rim of a pair of glasses. In a sharp photo those edges are crisp, and the Laplacian response is strong and dense. A blurry photo is effectively the same image passed through a low-pass filter first — the transitions get smoothed away and the response collapses. Motion blur and a dirty lens are, at heart, the same kind of smoothing.
The CV_64F argument matters: an edge is bright on one side and dark on the other, so the Laplacian’s output is naturally both positive and negative. Store it in the image’s default uint8 and every negative value gets clipped to zero — half the edge information gone on the spot. Hence 64-bit float.
Step three: .var(). Take the variance of that high-frequency edge matrix. A sharp image’s response is full of strong positive and negative peaks — values spread wide, variance high. A blurry image’s response huddles around zero — variance low. One scalar becomes the sharpness score, and a motion-blurred face scores conspicuously low.
Why Here
This is quality control before a photo ever reaches the AI model: motion-blurred frames and dirty-lens frames get filtered out and never enter the feature gallery.
I already paid the tuition on this in the last post: the embedding pulled from a blurry face has all its distinguishing detail wiped out — it’s the average of a smudged face, a little bit similar to everyone. And matching takes the best score across the whole gallery, so one junk frame in the gallery ruins the entire gallery.
Which is why the bouncer has to stand at the enrollment step. Keeping garbage data out of the door is far cheaper than trying to repair the damage after it’s inside.
Where It Lives in the Code
The scoring function itself:
@staticmethod
def _sharpness(image: np.ndarray) -> float:
"""Laplacian-variance sharpness: motion-blurred faces score conspicuously low"""
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
return float(cv2.Laplacian(gray, cv2.CV_64F).var())
It gets called from the enrollment pipeline, detect_and_extract — detect the largest face, align it, extract the feature, and compute the sharpness score on the way out:
det = self._detect_largest(frame)
aligned = self._recognizer.alignCrop(frame, det)
feature = self._recognizer.feature(aligned).flatten()
return det, feature, self._sharpness(aligned), aligned
One detail worth calling out: the thing being scored is not the full frame but aligned — the 112×112 pure-face crop after YuNet alignment, i.e. the exact pixels SFace actually consumes for the embedding. Two reasons for that.
First, the QC target should match the model input. The full frame can be razor sharp in the background — text on a bookshelf, the edge of a window frame — while the face is a smear, and it would still score high. Scoring only the face crop measures the thing that matters: whether this face is blurry.
Second, Laplacian variance has a famous weakness: the score depends heavily on resolution and image content, so scores from different sizes and different subjects aren’t comparable. But the aligned crop is always a 112×112 face — fixed size, fixed content — which is what makes the scores comparable to each other, and what makes a rejection threshold possible to set at all.
Afterword
It’s not magic. What the variance really measures is “high-frequency energy,” not semantic “sharpness”: a blurry photo drowning in sensor noise can cheat its way to a decent score, because noise is high frequency. But in my setting — same camera, fixed-size face crops, and the only job being to catch motion blur and a dirty lens — it’s good enough, and cheap enough that there’s no reason to hesitate on any frame.
As for the threshold: I shot a batch of deliberately shaky samples and a batch of normal ones, looked at where the two clumps of scores separated, and cut there. The bigger hole I dug at the end of the last post — auto-calibrating the similarity threshold from data — is still open. This post fills in the small one first.