DevLab / utilities / video-thumbnail-extractor-with-opencv
UTILITIES PYTHON INTERMEDIATE

Video Thumbnail Extractor with OpenCV

When managing large media libraries, lecture archives, or video processing pipelines, you often need to generate representative thumbnails without watching

Python3.10+
OSLinux / macOS / Windows
Lines~182
Packages1 deps
Install Dependencies
$ pip install opencv-python
Usage
$ python3 vid_thumb.py --video lecture.mp4 --every 30s --output ./thumbs/
#utilities#python#intermediate#opencv

Video Thumbnail Extractor with OpenCV

Problem Statement

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.

How It Works

The script follows a straightforward pipeline: open → compute timestamps → seek → read → resize → save. The core design decisions are:

  1. Timestamp-driven seeking — rather than decoding every frame and discarding most of them (slow and CPU-heavy), we compute the frame index for each interval and use cap.set(cv2.CAP_PROP_POS_FRAMES, idx) to jump directly. This is O(n) seeks rather than O(total_frames) reads.
  2. Interval parsing as a first-class citizen — the --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.
  3. Graceful degradation — some codecs don't support frame-accurate seeking. We read the actual frame back and verify it's non-empty, and we also fall back on CAP_PROP_POS_MSEC to compute real timestamps for filenames.
  4. Zero-config defaults via env vars — output directory, JPEG quality, and resize width are all overridable through environment variables, making the script drop-in friendly for CI pipelines and containerized jobs.

Full Script

#!/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())

Configuration

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.

Usage Examples

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

Error Handling

  • Missing video file → FileNotFoundError is raised and reported as [error] Video not found: ..., exit code 1.
  • Unopenable codec/container → cv2.VideoCapture.isOpened() returns False, we raise RuntimeError with the path, exit code 1.
  • Malformed --every value → parse_interval raises ValueError, caught in main, exit code 2 (argparse-style usage error).
  • Out-of-range --quality / --width → validated up front, exit code 2.
  • Unknown fps or frame count (common with some MKV/WebM streams) → falls back to sequential decoding via _extract_sequential, which uses CAP_PROP_POS_MSEC as the clock.
  • Codec keyframe snapping → we read the actual CAP_PROP_POS_MSEC after each seek so filenames reflect true timestamps, not the requested ones.
  • Write failures (permissions, disk full) → cv2.imwrite returns False; we log a warning and continue rather than aborting the whole batch.
  • Ctrl-C → caught and reported with exit code 130, and cap.release() always runs thanks to the try/finally.

Extending Ideas

  • Scene-change detection — replace fixed-interval seeking with histogram or perceptual-hash diffing between sampled frames, and only save thumbnails when the scene actually changes. Great for compressing lecture slides into a visual index.
  • Contact-sheet output — instead of individual JPEGs, tile N thumbnails into a single montage image using cv2.hconcat / vconcat, which is ideal for quick visual scanning of long videos.
  • Parallel processing — for multi-file batches, wrap 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.
  • Metadata sidecar — emit a JSON manifest alongside the images containing original filename, duration, fps, and each thumbnail's timestamp. Downstream tools (static site generators, DAM systems) can then ingest the thumbnails programmatically.
// RELATED_SCRIPTS
← Back to DevLab valtersit.com