Finding the one good frame in a 21-frame burst
I shoot bursts. Not "a burst when the moment calls for it" — I hold the shutter down and let the camera decide, 8 frames a second, and sort it out later. Four days of that produced 629 frames in about 100 bursts. One of them was 21 frames of the same escalator.
Sorting that by hand is the part that never happens. So I wrote something to do it.
Group first
A burst is consecutive frames from the same camera body inside a couple of seconds. The body matters — two cameras shooting the same moment are two bursts, not one, and if you group on time alone you get bursts that interleave frames from both and rank them against each other.
for m in sorted(metas, key=lambda m: (m["model"], m["ts"])):
if cur and m["model"] == cur[-1]["model"] and (m["ts"] - cur[-1]["ts"]) <= gap:
cur.append(m)
else:
bursts.append(cur); cur = [m]
EXIF SubsecTimeOriginal matters here too. At 8 fps, whole-second timestamps put five frames at the same instant and the sort order becomes whatever the filesystem felt like.
Score inside the burst, never across the shoot
My first pass ranked every frame globally by Laplacian variance. It produced a gallery of daylight exterior shots, because a sharp photo of a dark ballroom has less edge energy than a mediocre photo of a parking lot at noon.
Absolute sharpness is a property of the scene. Relative sharpness inside one burst — same subject, same light, same lens, frames 40 ms apart — is exactly the question you wanted to ask.
def _z(vals):
a = np.asarray(vals, float)
sd = a.std()
return np.zeros_like(a) if sd < 1e-9 else (a - a.mean()) / sd
Every signal goes through that before it's weighted. The score is meaningless as an absolute number and perfectly useful as an ordering.
OpenCV 5 took the cascades away
I reached for cv2.CascadeClassifier, the thing everyone has used for face detection since roughly forever, and got:
module 'cv2' has no attribute 'CascadeClassifier'
The Haar bindings are gone in OpenCV 5.0. The replacement is FaceDetectorYN — a small ONNX model you download separately — and it is a straight upgrade, because it returns five landmarks per face:
det = cv2.FaceDetectorYN.create("yunet.onnx", "", (320, 320), 0.6, 0.3, 5000)
det.setInputSize((w, h))
_, faces = det.detect(img)
# each row: x, y, w, h, then right-eye, left-eye, nose, two mouth corners, score
Two of those landmarks are the eyes, which gives you the signal that actually matters in a burst of people.
Blink detection without a blink detector
I didn't want to train anything. An open eye has an iris boundary, a sclera edge and usually a catchlight. A closed lid is smooth skin. That difference is just local edge energy:
def _eye_openness(gray, ex, ey, span):
r = max(4, int(span))
patch = gray[max(0,int(ey)-r):int(ey)+r, max(0,int(ex)-r):int(ex)+r]
return cv2.Laplacian(patch, cv2.CV_64F).var() + patch.std() * 4.0
Absolute values are noise. Z-scored across the twenty frames of one burst, where the only thing changing is whether the eyes are open, it separates cleanly.
One more thing that mattered: the subject is the biggest sharp face, not the biggest face. Weighting by sharpness * (0.5 + area) stops a blurry foreground head from outranking the person you were aiming at.
Make it fast by not decoding the whole file
24-megapixel JPEGs, 629 of them, decoded twice (score, then thumbnail). The fix is one flag:
img = cv2.imread(path, cv2.IMREAD_REDUCED_COLOR_4)
That pulls a quarter-scale image out of the JPEG's DCT coefficients — a 6016px frame becomes 1504px for about a sixth of the cost, and it's still far more resolution than a sharpness ranking needs. Same trick on the Pillow side is im.draft("RGB", size) before thumbnail().
With that plus a process pool, the whole shoot scores and renders in 70 seconds.
Result
629 frames -> 131 keepers (498 culled, 79%)
The output is a static page: keeper large, the rejects shrunk next to it with their scores, biggest bursts first. That last part matters more than it sounds — the tool earns its keep on the 21-frame bursts, and if you sort chronologically you open the page to a wall of single frames and assume it did nothing.
I don't trust it to delete anything, and I wouldn't build it to. It's a ranking with the evidence sitting right there, which is all I wanted: the 20 rejects are still on disk, and disagreeing with it costs one click.
What I'd do differently: score eye openness per-face rather than only on the dominant face. In a two-person shot the tool currently picks a frame where the main subject is mid-sentence with their eyes open and the other person is blinking, and calls it a win.