Skip to content

Thumbnail Extraction

SSIM-based thumbnail matching and extraction API.


📖 Module Documentation

thumbnail_extractor

Extract thumbnail images from video files using SSIM-based frame matching.

Classes

ThumbnailResult

Bases: NamedTuple

Result from a thumbnail extraction operation.

Attributes:

Name Type Description
success bool

Whether thumbnail extraction succeeded.

frame_number int

Frame number where match was found.

timestamp_ms float

Timestamp of matched frame in milliseconds.

ssim_score float

SSIM similarity score of the match (0.0-1.0).

output_path Path

Path where thumbnail image was saved.

ThumbnailExtractor
ThumbnailExtractor(
    video_path: Path,
    template_path: Optional[Path] = None,
    resolution: int = 3,
    scan_duration: int = 120,
    threshold: float = 0.8,
)

Extract thumbnail frames from videos using SSIM-based template matching.

Initialize ThumbnailExtractor.

Parameters:

Name Type Description Default
video_path Path

Path to video file

required
template_path Optional[Path]

Path to template image (uses default if None)

None
resolution int

Frames to check per second (matches Loups)

3
scan_duration int

Maximum seconds to scan from video start

120
threshold float

Minimum SSIM score to accept (0.0-1.0)

0.8
Source code in loups/thumbnail_extractor.py
def __init__(
    self,
    video_path: Path,
    template_path: Optional[Path] = None,
    resolution: int = 3,
    scan_duration: int = 120,
    threshold: float = 0.8,
):
    """
    Initialize ThumbnailExtractor.

    Args:
        video_path: Path to video file
        template_path: Path to template image (uses default if None)
        resolution: Frames to check per second (matches Loups)
        scan_duration: Maximum seconds to scan from video start
        threshold: Minimum SSIM score to accept (0.0-1.0)
    """
    self.video_path = video_path
    self.template = load_template(template_path)
    self.resolution = resolution
    self.scan_duration = scan_duration
    self.threshold = threshold
    self.capture = cv.VideoCapture(str(video_path))
    self.frame_rate = self.capture.get(cv.CAP_PROP_FPS)
Functions
frame_frequency
frame_frequency() -> int

Calculate frame sampling interval based on resolution.

Returns:

Type Description
int

Number of frames to skip between samples (e.g., 10 means every 10th frame).

Source code in loups/thumbnail_extractor.py
def frame_frequency(self) -> int:
    """Calculate frame sampling interval based on resolution.

    Returns:
        Number of frames to skip between samples (e.g., 10 means every 10th frame).
    """
    return calculate_frame_frequency(self.frame_rate, self.resolution)

Functions

get_default_thumbnail_template
get_default_thumbnail_template() -> Path

Get path to bundled default thumbnail template.

Returns:

Type Description
Path

Path to the bundled thumbnail_template.png file.

Source code in loups/thumbnail_extractor.py
def get_default_thumbnail_template() -> Path:
    """Get path to bundled default thumbnail template.

    Returns:
        Path to the bundled thumbnail_template.png file.
    """
    return Path(__file__).parent / "data" / "thumbnail_template.png"
load_template
load_template(
    template_path: Optional[Path] = None,
) -> ndarray

Load thumbnail template, using default if not specified.

Parameters:

Name Type Description Default
template_path Optional[Path]

Path to template image (None for default)

None

Returns:

Type Description
ndarray

Template image as numpy array

Raises:

Type Description
FileNotFoundError

If template file doesn't exist

Source code in loups/thumbnail_extractor.py
def load_template(template_path: Optional[Path] = None) -> np.ndarray:
    """
    Load thumbnail template, using default if not specified.

    Args:
        template_path: Path to template image (None for default)

    Returns:
        Template image as numpy array

    Raises:
        FileNotFoundError: If template file doesn't exist
    """
    if template_path is None:
        template_path = get_default_thumbnail_template()

    if not template_path.exists():
        raise FileNotFoundError(f"Template not found: {template_path}")

    return cv.imread(str(template_path))
generate_default_output_path
generate_default_output_path(video_path: Path) -> Path

Generate default thumbnail output path in current working directory.

Parameters:

Name Type Description Default
video_path Path

Path to input video file

required

Returns:

Type Description
Path

Path to output thumbnail in cwd

Examples:

Input: '/path/to/game.mp4' → Output: './game-thumbnail.jpg' Input: 'softball.mp4' → Output: './softball-thumbnail.jpg'

Source code in loups/thumbnail_extractor.py
def generate_default_output_path(video_path: Path) -> Path:
    """
    Generate default thumbnail output path in current working directory.

    Args:
        video_path: Path to input video file

    Returns:
        Path to output thumbnail in cwd

    Examples:
        Input: '/path/to/game.mp4' → Output: './game-thumbnail.jpg'
        Input: 'softball.mp4' → Output: './softball-thumbnail.jpg'
    """
    stem = video_path.stem
    return Path.cwd() / f"{stem}-thumbnail.jpg"
calculate_ssim
calculate_ssim(frame: ndarray, template: ndarray) -> float

Calculate SSIM (Structural Similarity Index) between frame and template.

Parameters:

Name Type Description Default
frame ndarray

Video frame as numpy array

required
template ndarray

Template image as numpy array

required

Returns:

Type Description
float

SSIM score (0.0 to 1.0, where 1.0 is perfect match)

Source code in loups/thumbnail_extractor.py
def calculate_ssim(frame: np.ndarray, template: np.ndarray) -> float:
    """
    Calculate SSIM (Structural Similarity Index) between frame and template.

    Args:
        frame: Video frame as numpy array
        template: Template image as numpy array

    Returns:
        SSIM score (0.0 to 1.0, where 1.0 is perfect match)
    """
    # Resize frame to match template dimensions
    frame_resized = cv.resize(frame, (template.shape[1], template.shape[0]))

    # Convert to grayscale
    frame_gray = cv.cvtColor(frame_resized, cv.COLOR_BGR2GRAY)
    template_gray = cv.cvtColor(template, cv.COLOR_BGR2GRAY)

    # Calculate SSIM
    score = ssim(frame_gray, template_gray)
    return score
extract_thumbnail
extract_thumbnail(
    video_path: Path,
    template_path: Optional[Path] = None,
    output_path: Optional[Path] = None,
    threshold: float = 0.35,
    scan_duration: int = 120,
    resolution: int = 3,
    on_progress: Optional[
        Callable[[int, int], None]
    ] = None,
    quiet: bool = False,
) -> Optional[ThumbnailResult]

Extract first frame matching template above threshold.

Core thumbnail extraction logic used by both CLI commands. Scans video from start, checking frames at specified resolution. Stops immediately when a frame exceeds the SSIM threshold.

Parameters:

Name Type Description Default
video_path Path

Path to video file

required
template_path Optional[Path]

Path to template image (uses default if None)

None
output_path Optional[Path]

Where to save thumbnail (generates default in cwd if None)

None
threshold float

Minimum SSIM score to accept (0.0-1.0)

0.35
scan_duration int

Maximum seconds to scan from video start

120
resolution int

Frames to process per second

3
on_progress Optional[Callable[[int, int], None]]

Optional callback for progress updates

None
quiet bool

Suppress output

False

Returns:

Type Description
Optional[ThumbnailResult]

ThumbnailResult on success, None if no frame exceeds threshold

Source code in loups/thumbnail_extractor.py
def extract_thumbnail(
    video_path: Path,
    template_path: Optional[Path] = None,
    output_path: Optional[Path] = None,
    threshold: float = 0.35,
    scan_duration: int = 120,
    resolution: int = 3,
    on_progress: Optional[Callable[[int, int], None]] = None,
    quiet: bool = False,
) -> Optional[ThumbnailResult]:
    """
    Extract first frame matching template above threshold.

    Core thumbnail extraction logic used by both CLI commands.
    Scans video from start, checking frames at specified resolution.
    Stops immediately when a frame exceeds the SSIM threshold.

    Args:
        video_path: Path to video file
        template_path: Path to template image (uses default if None)
        output_path: Where to save thumbnail (generates default in cwd if None)
        threshold: Minimum SSIM score to accept (0.0-1.0)
        scan_duration: Maximum seconds to scan from video start
        resolution: Frames to process per second
        on_progress: Optional callback for progress updates
        quiet: Suppress output

    Returns:
        ThumbnailResult on success, None if no frame exceeds threshold
    """
    extractor = ThumbnailExtractor(
        video_path=video_path,
        template_path=template_path,
        resolution=resolution,
        scan_duration=scan_duration,
        threshold=threshold,
    )

    max_frames = int(scan_duration * extractor.frame_rate)
    frame_interval = extractor.frame_frequency()

    logger.debug(
        f"Scanning {video_path.name}: max_frames={max_frames}, "
        f"frame_interval={frame_interval}, threshold={threshold}"
    )

    frame_count = 0
    frames_checked = 0

    while frame_count < max_frames:
        ret = extractor.capture.grab()
        if not ret:
            break

        frame_count += 1

        # Sample at interval (same pattern as Loups.scan())
        if frame_count % frame_interval != 0:
            continue

        ret, frame = extractor.capture.retrieve()
        if not ret:
            break

        frames_checked += 1
        score = calculate_ssim(frame, extractor.template)

        logger.debug(f"Frame {frame_count}: SSIM score = {score:.4f}")

        # Call progress callback if provided
        if on_progress and not quiet:
            on_progress(frame_count, max_frames)

        # First frame above threshold wins!
        if score >= threshold:
            output = output_path or generate_default_output_path(video_path)
            cv.imwrite(str(output), frame)
            timestamp = extractor.capture.get(cv.CAP_PROP_POS_MSEC)

            logger.info(
                f"Thumbnail extracted: frame={frame_count}, "
                f"timestamp={timestamp:.0f}ms, score={score:.4f}, path={output}"
            )

            return ThumbnailResult(
                success=True,
                frame_number=frame_count,
                timestamp_ms=timestamp,
                ssim_score=score,
                output_path=output,
            )

    # No match found - log warning and return None
    logger.warning(
        f"No frame exceeded threshold {threshold} "
        f"within {scan_duration}s (checked {frames_checked} frames)"
    )
    return None

:material-flow-chart: Extraction Process

graph TD
    A[Start: Video + Template] --> B{Load Template}
    B --> C[Initialize VideoCapture]
    C --> D{Scan Frames}

    D --> E[Read Next Frame]
    E --> F{Frame Valid?}

    F -->|No| G[End: No Match Found]
    F -->|Yes| H[Resize to Template Size]

    H --> I[Calculate SSIM Score]
    I --> J{Score >= Threshold?}

    J -->|No| K{More Frames?}
    K -->|Yes, within duration| E
    K -->|No, exceeded duration| G

    J -->|Yes| L[Match Found!]
    L --> M[Save as JPEG]
    M --> N[End: Success]

    style A fill:#00ffff,stroke:#000,color:#000
    style L fill:#66bb6a,stroke:#000,color:#000
    style N fill:#00ffff,stroke:#000,color:#000
    style G fill:#ef5350,stroke:#000,color:#fff
    style I fill:#00b8d4,stroke:#000,color:#fff

Key Steps:

  1. Template Loading - Load and validate template image
  2. Frame Scanning - Iterate through video frames at specified FPS
  3. SSIM Calculation - Compute Structural Similarity Index
  4. Threshold Check - Compare against minimum threshold
  5. First-Match Strategy - Stop immediately on first match
  6. Save Output - Write matched frame as JPEG

⚡ Usage Examples

Basic Extraction

from loups.thumbnail_extractor import extract_thumbnail

# Extract with default template
thumbnail_path = extract_thumbnail(
    video_path="game_video.mp4"
)

print(f"Thumbnail saved to: {thumbnail_path}")

Custom Template

from loups.thumbnail_extractor import extract_thumbnail

thumbnail_path = extract_thumbnail(
    video_path="video.mp4",
    template_path="title_screen_template.png",
    output_path="custom_thumbnail.jpg"
)

Fine-Tuned Extraction

from loups.thumbnail_extractor import extract_thumbnail

thumbnail_path = extract_thumbnail(
    video_path="video.mp4",
    template_path="template.png",
    output_path="thumb.jpg",
    threshold=0.7,           # Stricter matching (default: 0.35)
    scan_duration=180,       # Scan first 3 minutes (default: 120)
    frames_per_second=5,     # Sample 5 FPS (default: 3)
    quiet=False              # Show progress
)

Parameters Explained

Threshold (0.0 - 1.0)

The SSIM threshold determines how similar a frame must be to the template:

Threshold Matching Behavior Use Case
0.2 - 0.4 Loose - More matches Varied title screens
0.5 - 0.7 Balanced - Moderate Most use cases
0.8 - 1.0 Strict - Exact match Identical frames only

Default: 0.35 (loose, good for varied content)

Finding the Right Threshold

Start with default (0.35) and adjust:

  • Too many false positives? → Increase threshold
  • Can't find match? → Decrease threshold
  • Test on sample to find sweet spot

Scan Duration

How many seconds to scan from the beginning:

# Scan first 2 minutes
extract_thumbnail(video_path="video.mp4", scan_duration=120)

# Scan first 5 minutes
extract_thumbnail(video_path="video.mp4", scan_duration=300)

# Scan entire video (slow!)
extract_thumbnail(video_path="video.mp4", scan_duration=999999)

Performance Impact

Longer scan duration = slower extraction. Most title screens appear in first 2 minutes.

Frames Per Second

Frame sampling rate:

# Sample every frame (slow, thorough)
extract_thumbnail(video_path="video.mp4", frames_per_second=30)

# Sample 3 FPS (default, balanced)
extract_thumbnail(video_path="video.mp4", frames_per_second=3)

# Sample 1 FPS (fast, might miss frame)
extract_thumbnail(video_path="video.mp4", frames_per_second=1)

Trade-off: Higher FPS = more accurate but slower


SSIM vs Template Matching

Loups uses SSIM (Structural Similarity Index) for thumbnails instead of template matching:

Feature Template Matching SSIM
Purpose Find specific regions Compare full frames
Speed Fast Moderate
Accuracy High for patterns High for images
Use Case Chapter detection Thumbnail extraction
Sensitivity Position-sensitive Content-aware

Why SSIM for thumbnails?

  • Accounts for overall visual similarity
  • Robust to minor variations
  • Good for comparing full frames
  • Perceptually meaningful

Testing & Debugging

Verify Template Quality

import cv2
from skimage.metrics import structural_similarity as ssim

# Load template and test frame
template = cv2.imread("template.png")
test_frame = cv2.imread("test_frame.png")

# Resize to same size
test_frame_resized = cv2.resize(
    test_frame,
    (template.shape[1], template.shape[0])
)

# Calculate SSIM
score = ssim(template, test_frame_resized, multichannel=True)
print(f"SSIM Score: {score:.3f}")

# Interpretation
if score >= 0.7:
    print("✅ Strong match")
elif score >= 0.4:
    print("⚠️ Moderate match")
else:
    print("❌ Poor match")

Extract Multiple Frames

For testing, you might want to extract multiple frames:

import cv2
from pathlib import Path

def extract_frames(video_path, output_dir, fps=1, duration=120):
    """Extract frames for template testing."""
    output_dir = Path(output_dir)
    output_dir.mkdir(exist_ok=True)

    cap = cv2.VideoCapture(video_path)
    video_fps = cap.get(cv2.CAP_PROP_FPS)
    frame_interval = int(video_fps / fps)

    frame_count = 0
    saved_count = 0

    while frame_count < (duration * video_fps):
        ret, frame = cap.read()
        if not ret:
            break

        if frame_count % frame_interval == 0:
            output_path = output_dir / f"frame_{saved_count:04d}.jpg"
            cv2.imwrite(str(output_path), frame)
            saved_count += 1

        frame_count += 1

    cap.release()
    print(f"Extracted {saved_count} frames to {output_dir}")

# Usage
extract_frames("video.mp4", "test_frames", fps=1, duration=60)

Batch Thumbnail Extraction

Process Multiple Videos

from pathlib import Path
from loups.thumbnail_extractor import extract_thumbnail

video_dir = Path("videos")
thumb_dir = Path("thumbnails")
thumb_dir.mkdir(exist_ok=True)

for video in video_dir.glob("*.mp4"):
    print(f"Extracting thumbnail for: {video.name}")

    try:
        thumbnail = extract_thumbnail(
            video_path=str(video),
            output_path=str(thumb_dir / f"{video.stem}.jpg"),
            quiet=True
        )
        print(f"  ✅ Saved: {thumbnail}")
    except Exception as e:
        print(f"  ❌ Error: {e}")

Parallel Processing

from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from loups.thumbnail_extractor import extract_thumbnail

def extract_single(video_path):
    """Extract thumbnail for single video."""
    try:
        output = f"thumbnails/{video_path.stem}.jpg"
        return extract_thumbnail(
            video_path=str(video_path),
            output_path=output,
            quiet=True
        )
    except Exception as e:
        return f"Error: {e}"

# Get all videos
videos = list(Path("videos").glob("*.mp4"))

# Process in parallel
with ThreadPoolExecutor(max_workers=4) as executor:
    results = executor.map(extract_single, videos)

# Print results
for video, result in zip(videos, results):
    print(f"{video.name}: {result}")

💡 Tips & Best Practices

Best Practices

  • Template from actual frame - Use a real frame from your video
  • Start with defaults - Adjust only if needed
  • Test on one video first - Verify settings work
  • Use quiet mode in scripts - Cleaner output

Common Issues

No match found?

  • Try lower threshold (0.2 - 0.3)
  • Increase scan duration
  • Increase frames_per_second
  • Verify template matches video content

Wrong frame matched?

  • Increase threshold for stricter matching
  • Use more specific template
  • Reduce scan duration if title appears early