When managing large media libraries, lecture archives, or video processing pipelines, you often need to generate representative thumbnails without watching
When managing large media libraries, lecture archives, or video processing pipelines, you often need to generate representative thumbnails without watching entire videos or running heavyweight transcoding tools like FFmpeg. Manually extracting frames, cropping them, and naming files consistently is tedious and doesn't scale — especially when you have hundreds of lecture recordings to process for a course catalog, or you need preview images for a video-on-demand platform.
This script solves that by walking through a video at a configurable interval (e.g., every 30 seconds) and saving a JPEG frame at each stop point, with filenames that encode the timestamp for easy sorting and lookup. It uses OpenCV's VideoCapture for frame seeking, which is fast, dependency-light, and works across MP4, AVI, MOV, and most container formats you'll encounter in the wild.
The script follows a straightforward pipeline: open → compute timestamps → seek → read → resize → save. The core design decisions are:
cap.set(cv2.CAP_PROP_POS_FRAMES, idx) to jump directly. This is O(n) seeks rather than O(total_frames) reads.--every flag accepts human-friendly strings like 30s, 2m, 500ms, or a bare number (interpreted as seconds). A small parser converts this to a float of seconds.CAP_PROP_POS_MSEC to compute real timestamps for filenames.#!/usr/bin/env python3
"""
Video Thumbnail Extractor with OpenCV
Extracts evenly-spaced JPEG thumbnails from a video file at a configurable
interval. Useful for lecture archives, media catalogs, and video pipelines.
Usage:
python3 vid_thumb.py --video lecture.mp4 --every 30s --output ./thumbs/
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from pathlib import Path
from typing import Iterator
import cv2
# ---------------------------------------------------------------------------
# Configuration (env vars with sensible defaults)
# ---------------------------------------------------------------------------
DEFAULT_OUTPUT_DIR = os.environ.get("VID_THUMB_OUTPUT", "./thumbs")
DEFAULT_INTERVAL = os.environ.get("VID_THUMB_INTERVAL", "30s")
DEFAULT_WIDTH = int(os.environ.get("VID_THUMB_WIDTH", "640"))
DEFAULT_QUALITY = int(os.environ.get("VID_THUMB_QUALITY", "85")) # 0-100 JPEG
DEFAULT_PREFIX = os.environ.get("VID_THUMB_PREFIX", "thumb")
# Regex for parsing intervals like "30s", "2m", "500ms", "1.5h", or bare "30"
_INTERVAL_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$", re.IGNORECASE)
_UNIT_SECONDS = {"ms": 0.001, "s": 1.0, "m": 60.0, "h": 3600.0}
def parse_interval(value: str) -> float:
"""Parse a human-friendly interval string into seconds.
Accepts: '30', '30s', '2m', '500ms', '1.5h'. Bare numbers default to seconds.
Raises ValueError on malformed input.
"""
match = _INTERVAL_RE.match(value)
if not match:
raise ValueError(f"Invalid interval format: {value!r} (expected e.g. '30s', '2m')")
number, unit = match.groups()
seconds = float(number) * _UNIT_SECONDS.get((unit or "s").lower(), 1.0)
if seconds <= 0:
raise ValueError(f"Interval must be positive, got {seconds}s")
return seconds
def format_timestamp(seconds: float) -> str:
"""Format seconds as HH-MM-SS-mmm for safe use in filenames."""
total_ms = int(round(seconds * 1000))
hours, rem = divmod(total_ms, 3_600_000)
minutes, rem = divmod(rem, 60_000)
secs, millis = divmod(rem, 1000)
return f"{hours:02d}-{minutes:02d}-{secs:02d}-{millis:03d}"
def iter_frames(cap: cv2.VideoCapture, interval_sec: float, fps: float,
total_frames: int) -> Iterator[tuple[int, float, "cv2.Mat"]]:
"""Yield (frame_index, timestamp_seconds, frame) at each interval step."""
step_frames = max(1, int(round(interval_sec * fps)))
idx = 0
while idx < total_frames:
# Seek to the target frame; some codecs snap to nearest keyframe.
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
ok, frame = cap.read()
if not ok or frame is None:
# End of stream or unreadable frame — stop cleanly.
break
# Prefer the actual reported position (handles keyframe snapping).
actual_msec = cap.get(cv2.CAP_PROP_POS_MSEC)
timestamp = actual_msec / 1000.0 if actual_msec > 0 else idx / fps
yield idx, timestamp, frame
idx += step_frames
def resize_keep_aspect(frame: "cv2.Mat", target_width: int) -> "cv2.Mat":
"""Resize frame to target width, preserving aspect ratio. No-op if already smaller."""
h, w = frame.shape[:2]
if w <= target_width:
return frame
scale = target_width / float(w)
new_size = (target_width, max(1, int(round(h * scale))))
return cv2.resize(frame, new_size, interpolation=cv2.INTER_AREA)
def extract_thumbnails(video_path: Path, output_dir: Path, interval_sec: float,
width: int, quality: int, prefix: str) -> int:
"""Extract thumbnails from video_path into output_dir. Returns count saved."""
if not video_path.is_file():
raise FileNotFoundError(f"Video not found: {video_path}")
output_dir.mkdir(parents=True, exist_ok=True)
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
raise RuntimeError(f"OpenCV could not open video: {video_path}")
try:
fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
# Some streams report 0 fps/frames; fall back to a conservative estimate.
if fps <= 0 or total_frames <= 0:
print("[warn] Video reports unknown fps/frame count; "
"falling back to sequential decoding.", file=sys.stderr)
return _extract_sequential(cap, output_dir, interval_sec,
width, quality, prefix)
duration = total_frames / fps
print(f"[info] {video_path.name}: {duration:.1f}s @ {fps:.2f} fps, "
f"{total_frames} frames")
# JPEG quality is 0-100; OpenCV uses 0-100 too.
encode_params = [int(cv2.IMWRITE_JPEG_QUALITY), max(1, min(100, quality))]
saved = 0
for idx, ts, frame in iter_frames(cap, interval_sec, fps, total_frames):
frame = resize_keep_aspect(frame, width)
fname = f"{prefix}_{format_timestamp(ts)}.jpg"
out_path = output_dir / fname
ok = cv2.imwrite(str(out_path), frame, encode_params)
if not ok:
print(f"[warn] Failed to write {out_path}", file=sys.stderr)
continue
saved += 1
print(f"[ok] frame {idx:>7} t={ts:8.3f}s -> {out_path}")
return saved
finally:
cap.release()
def _extract_sequential(cap: cv2.VideoCapture, output_dir: Path, interval_sec: float,
width: int, quality: int, prefix: str) -> int:
"""Fallback path: decode every frame, save one every interval_sec of wall time."""
encode_params = [int(cv2.IMWRITE_JPEG_QUALITY), max(1, min(100, quality))]
saved = 0
next_save_at = 0.0
while True:
ok, frame = cap.read()
if not ok or frame is None:
break
ts = cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0
if ts + 1e-6 >= next_save_at:
frame = resize_keep_aspect(frame, width)
fname = f"{prefix}_{format_timestamp(ts)}.jpg"
out_path = output_dir / fname
if cv2.imwrite(str(out_path), frame, encode_params):
saved += 1
print(f"[ok] t={ts:8.3f}s -> {out_path}")
next_save_at = ts + interval_sec
return saved
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="Extract evenly-spaced thumbnails from a video using OpenCV.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--video", required=True, type=Path,
help="Path to input video file.")
p.add_argument("--every", default=DEFAULT_INTERVAL,
help="Interval between thumbnails (e.g. 30s, 2m, 500ms).")
p.add_argument("--output", default=DEFAULT_OUTPUT_DIR, type=Path,
help="Directory to write thumbnails into.")
p.add_argument("--width", type=int, default=DEFAULT_WIDTH,
help="Target thumbnail width in pixels (aspect preserved).")
p.add_argument("--quality", type=int, default=DEFAULT_QUALITY,
help="JPEG quality 1-100.")
p.add_argument("--prefix", default=DEFAULT_PREFIX,
help="Filename prefix for generated thumbnails.")
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
interval = parse_interval(args.every)
except ValueError as exc:
print(f"[error] {exc}", file=sys.stderr)
return 2
if not 1 <= args.quality <= 100:
print("[error] --quality must be between 1 and 100", file=sys.stderr)
return 2
if args.width < 16:
print("[error] --width must be at least 16 pixels", file=sys.stderr)
return 2
try:
count = extract_thumbnails(
video_path=args.video,
output_dir=args.output,
interval_sec=interval,
width=args.width,
quality=args.quality,
prefix=args.prefix,
)
except FileNotFoundError as exc:
print(f"[error] {exc}", file=sys.stderr)
return 1
except RuntimeError as exc:
print(f"[error] {exc}", file=sys.stderr)
return 1
except KeyboardInterrupt:
print("\n[info] Interrupted by user.", file=sys.stderr)
return 130
print(f"[done] Wrote {count} thumbnail(s) to {args.output}")
return 0
if __name__ == "__main__":
sys.exit(main())
All settings can be provided either via CLI flags (which take precedence) or environment variables (useful for containers and CI):
| Env Var | CLI Flag | Default | Purpose |
|---|---|---|---|
VID_THUMB_OUTPUT |
--output |
./thumbs |
Destination directory; created if missing. |
VID_THUMB_INTERVAL |
--every |
30s |
Time between thumbnails. Accepts 30, 30s, 2m, 500ms, 1.5h. |
VID_THUMB_WIDTH |
--width |
640 |
Target width in pixels; height scales to preserve aspect ratio. |
VID_THUMB_QUALITY |
--quality |
85 |
JPEG quality (1–100). Lower = smaller files, more artifacts. |
VID_THUMB_PREFIX |
--prefix |
thumb |
Filename prefix; timestamps are appended as HH-MM-SS-mmm. |
The interval parser is intentionally permissive: bare numbers default to seconds, so --every 30 and --every 30s are equivalent.
1. Basic extraction every 30 seconds:
$ python3 vid_thumb.py --video lecture.mp4 --every 30s --output ./thumbs/
[info] lecture.mp4: 1832.4s @ 29.97 fps, 54912 frames
[ok] frame 0 t= 0.000s -> thumbs/thumb_00-00-00-000.jpg
[ok] frame 899 t= 30.000s -> thumbs/thumb_00-00-30-000.jpg
[ok] frame 1798 t= 60.000s -> thumbs/thumb_00-01-00-000.jpg
...
[done] Wrote 62 thumbnail(s) to thumbs
2. Dense sampling with smaller thumbs for a web gallery:
$ VID_THUMB_WIDTH=320 VID_THUMB_QUALITY=70 \
python3 vid_thumb.py --video promo.mov --every 5s --output ./web-thumbs/
3. Container-friendly invocation using env vars only:
$ docker run --rm -v "$PWD:/data" \
-e VID_THUMB_OUTPUT=/data/out \
-e VID_THUMB_INTERVAL=1m \
-e VID_THUMB_PREFIX=preview \
my-image python3 vid_thumb.py --video /data/input.mp4
FileNotFoundError is raised and reported as [error] Video not found: ..., exit code 1.cv2.VideoCapture.isOpened() returns False, we raise RuntimeError with the path, exit code 1.--every value → parse_interval raises ValueError, caught in main, exit code 2 (argparse-style usage error).--quality / --width → validated up front, exit code 2._extract_sequential, which uses CAP_PROP_POS_MSEC as the clock.CAP_PROP_POS_MSEC after each seek so filenames reflect true timestamps, not the requested ones.cv2.imwrite returns False; we log a warning and continue rather than aborting the whole batch.130, and cap.release() always runs thanks to the try/finally.cv2.hconcat / vconcat, which is ideal for quick visual scanning of long videos.extract_thumbnails in a concurrent.futures.ProcessPoolExecutor; OpenCV releases the GIL during decoding, but process isolation avoids shared-state issues and lets you saturate multi-core hosts.