◆ SHIPPEDComputer vision

Score every frame, keep only the good ones.

A Python 3.12 pipeline and terminal app that decodes every frame of a clip, scores each one on the GPU with a no-reference image-quality model, keeps every score in SQLite, and exports the best, most representative stills through FFmpeg. The interesting part is what the models got wrong: a benchmark caught them favoring blurry frames over sharp ones. The August 2026 rebuild replaced them and added a test where a blurred copy has to lose to its sharp original.

PythonPyTorchSQLite
Python 3.12CUDApyiqaTOPIQ / MUSIQ (SPAQ)PyAVOpenCV fallbackFFmpeg / ffprobeMediaInfoExifToolSQLite WALRichuvpytestmypy / Ruff
9
quality models benchmarked against a sharpness reference
2
smartphone-trained models shipped: TOPIQ and MUSIQ
<0.5 GB
VRAM growth allowed over 40 4K frames, test-enforced
78
test cases across 10 modules, including GPU-only gates
frame-analysis
// The problem

A video has thousands of frames. You want the handful worth keeping.

A 15-minute clip at 60 fps is 54,000 frames, and maybe a dozen of them are worth keeping — for a thumbnail, a print, a dataset, or an archive. Scrubbing by hand is slow and inconsistent. Grabbing a frame every few seconds is worse: it lands on motion blur and mid-blink just as happily as on the shot you wanted.

So I built a repeatable pipeline instead of a one-off script: score every decoded frame, keep the raw scores, flag the best frames plus a spread across the timeline, and name each still with its frame number, rank, and score. The catch is that it has to survive real footage — variable frame rates, higher bit depths, 4K frames that eat VRAM, external tools with opinions, and one drive that gave up mid-write.

// How it works

Decode, score, persist, select, export.

SQLite is the source of truth — every frame's score is still there after the run.

01 DECODE
PyAV decodes first, OpenCV is the fallback, and nothing is sampled. Frames come out as rgb24 for 8-bit sources or rgb48le for deeper ones, and PyAV times them from the stream itself, so variable-frame-rate clips keep honest timestamps.
02 PREP
Batches leave the decoder already on the GPU, where they're reshaped to NCHW, normalized to [0, 1], and clamped. Batch size is the smaller of a configured cap and what 90% of free VRAM holds at that resolution.
03 SCORE
One of two smartphone-trained pyiqa models scores each frame: TOPIQ by default, at a fixed 512 px working size, or MUSIQ at native resolution. Scoring runs in fp32 with TF32 math and one GPU-to-CPU copy per batch, and a NaN is stored as unrated.
04 PERSIST
Scores land in SQLite in buffered transactions of 100, unique on (video, frame), with WAL mode, NORMAL sync, and four indexes keeping writes cheap and queries quick.
05 SELECT
Frames rank by score, ties broken by frame number. Up to 16 top picks stay 250 ms apart on clips of 200+ frames; up to 24 diverse picks take the best remaining frame from each slice of the timeline, above a 60th-percentile floor. Short clips relax the floor and backfill.
06 EXPORT
FFmpeg pulls the picks in batches of 100 and checks each batch's file count. Stills are PNG or TIFF at 8 or 16 bits per channel, named with the frame number, rank, and score.
// What was hard

Where real footage pushed back.

The models were confidently wrong, the workstation froze, and a drive gave up. Most of the fixes left a test behind.

Blur was winning. Soft frames kept landing near the top, so I measured instead of squinting. Nine candidate models scored 145 frames from a real 4K phone clip against a sharpness reference, and seven were negatively correlated with it, including DBCNN, the old default, at −0.15. Sharpness isn't quality, but a quality model that prefers blur is wrong about something. The old models were trained on web photos, not phone photos, and were scoring native 4K frames because the 512 px working size meant to prevent that was silently ignored: the code only forwarded it if the library's defaults already listed the key, and TOPIQ's don't. The August 2026 rebuild kept two smartphone-trained (SPAQ) models — MUSIQ-SPAQ led the benchmark at +0.56 — and forces TOPIQ to 512 px. A GPU test checks the live model rather than trusting a log line, and a fixture test fails if a blurred copy beats its sharp original.

One allocator setting froze the workstation. Reserved VRAM grew about 1 GB with every 1088×1920 portrait frame until the Windows driver spilled into system RAM and the machine locked up. Reboot-and-guess is a slow loop, so I wrote a guarded repro that bails out below 3 GB of free VRAM and toggles one suspect at a time. The cause was max_split_size_mb:256, a setting meant to reduce fragmentation. With the default allocator, memory stayed flat. The setting is gone, the model-download path clears it, and a GPU test holds reserved-memory growth under 0.5 GB across 40 4K frames.

Faster was producing NaNs. Mixed precision overflowed fp16 inside the metrics, and some frames came back as NaN. A guard that retried in fp32 came first, then a side-by-side run of fp32, fp16, and bf16, then the boring fix: AMP is off, even if the config asks for it. A NaN is now stored as unrated, never as a number that looks real.

Percentages don't scale. "Keep the top 2%" sounds reasonable until a 15-minute, 60 fps clip hands you 1,080 stills. Selection now uses clamped counts — at most 16 top and 24 diverse picks per clip — plus a 60th-percentile quality floor and a 250 ms gap so the top picks aren't one burst of near-duplicates.

The TIFFs stopped at exactly 256 KB. FFmpeg exited with 4294967274 — that's −22, EINVAL, read as an unsigned number — and 16-bit TIFFs that should have been about 3.4 MB were cut off at 256 KB. I ruled out seven suspects one at a time: pixel format, color flags, frame-sync flags, CUDA decode, select-filter escaping, path length, and codec and container. The cause was the disk: a failing external exFAT drive that rejected flush calls. The same export worked on NTFS, so the fix was a different drive. Along the way, a one-time MD5 comparison of batch and single-frame exports confirmed that frame numbers are 0-based end to end.

Not every idea survived. GPU decode through TorchCodec lasted two days in 2025; one commit is titled "not working." A vision-language reranker scored −0.46 against sharpness and needed the network on first use, so it's switched off.

// The boring parts

Scores are the asset. Stills are disposable.

Export is where tools like this break, and the database is why a re-export never touches the model.

The exporter expects FFmpeg to have a bad day. Frames go out in batches of 100 because one giant select=eq(n\,123)+eq(n\,456)+... expression can blow past command-length limits on Windows. On a wrong file count, the partials are deleted and those frames go out one at a time, so a filename never points at the wrong frame. Batches get three attempts, failed GPU frames retry on the CPU, a disk-space check with a 5 GB margin runs first, and nothing goes through a shell.

Every run leaves a trail. Each clip gets a workspace with its database, log, stills, and metadata, and the source clip moves to a Processed/ folder beside it. "Export from a database" writes the stored picks again without rerunning the model, and relative paths read from a database must resolve inside the project before FFmpeg sees them. A follow-up still in review makes re-export re-select with the current settings first.