docs: Add 24 files
All checks were successful
Test and Publish Templates / test-and-publish (push) Successful in 48s
All checks were successful
Test and Publish Templates / test-and-publish (push) Successful in 48s
This commit is contained in:
31
squashthumbnailmaker/Dockerfile
Normal file
31
squashthumbnailmaker/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Install ffmpeg and other dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libsm6 \
|
||||||
|
libxext6 \
|
||||||
|
libxrender-dev \
|
||||||
|
libgomp1 \
|
||||||
|
wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create app directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
ultralytics \
|
||||||
|
opencv-python-headless \
|
||||||
|
numpy
|
||||||
|
|
||||||
|
# Copy the thumbnail generation script
|
||||||
|
COPY generate_thumbnail.py /app/
|
||||||
|
|
||||||
|
# Make script executable
|
||||||
|
RUN chmod +x /app/generate_thumbnail.py
|
||||||
|
|
||||||
|
# Set the entrypoint
|
||||||
|
ENTRYPOINT ["python", "/app/generate_thumbnail.py"]
|
75
squashthumbnailmaker/README.txt
Normal file
75
squashthumbnailmaker/README.txt
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
Squash Thumbnail Maker
|
||||||
|
======================
|
||||||
|
|
||||||
|
A Docker-based utility for generating high-quality thumbnails from MP4 videos using intelligent frame detection.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
---------
|
||||||
|
- Smart thumbnail generation using YOLOv8 pose detection to find frames with both players visible
|
||||||
|
- Fallback to simple thumbnail extraction if smart detection fails
|
||||||
|
- Process single videos or entire directories
|
||||||
|
- Configurable thumbnail quality and size
|
||||||
|
- Low priority execution to avoid system impact
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
------
|
||||||
|
After installation, use the Docker container to process videos:
|
||||||
|
|
||||||
|
# Process a single video file
|
||||||
|
docker run --rm -v /path/to/videos:/data squashthumbnailmaker_image:latest /data/video.mp4
|
||||||
|
|
||||||
|
# Process all videos in a directory
|
||||||
|
docker run --rm -v /path/to/videos:/data squashthumbnailmaker_image:latest /data
|
||||||
|
|
||||||
|
# With custom settings
|
||||||
|
docker run --rm \
|
||||||
|
-e ENABLE_SMART_DETECTION=false \
|
||||||
|
-e THUMBNAIL_WIDTH=1280 \
|
||||||
|
-e THUMBNAIL_QUALITY=90 \
|
||||||
|
-v /path/to/videos:/data \
|
||||||
|
squashthumbnailmaker_image:latest /data
|
||||||
|
|
||||||
|
Configuration Options:
|
||||||
|
----------------------
|
||||||
|
The following environment variables can be set in config/service.env or passed with -e flag:
|
||||||
|
|
||||||
|
- ENABLE_SMART_DETECTION: Use YOLO for intelligent thumbnail generation (default: true)
|
||||||
|
- FALLBACK_TIME: Seconds into video for fallback thumbnail (default: 10)
|
||||||
|
- THUMBNAIL_WIDTH: Width of generated thumbnails in pixels (default: 640)
|
||||||
|
- THUMBNAIL_QUALITY: JPEG quality 1-100 (default: 95)
|
||||||
|
- MAX_SAMPLE_TIME: Maximum seconds to sample in video (default: 180)
|
||||||
|
- SAMPLE_INTERVAL: Interval between samples in seconds (default: 1)
|
||||||
|
- NICE_LEVEL: Process priority 0-19, 19 is lowest (default: 19)
|
||||||
|
|
||||||
|
How It Works:
|
||||||
|
-------------
|
||||||
|
1. Smart Detection Mode (if enabled):
|
||||||
|
- Samples frames throughout the video
|
||||||
|
- Uses YOLOv8 pose model to detect players
|
||||||
|
- Scores frames based on:
|
||||||
|
* Number of players visible (ideally 2)
|
||||||
|
* Face visibility (crucial for good thumbnails)
|
||||||
|
* Player size and positioning
|
||||||
|
* Players being centered in frame
|
||||||
|
- Fine-tunes around the best frame for optimal result
|
||||||
|
|
||||||
|
2. Simple Mode (fallback):
|
||||||
|
- Extracts frame at specified time (default 10 seconds)
|
||||||
|
- Falls back to 1 second if initial extraction fails
|
||||||
|
|
||||||
|
Output:
|
||||||
|
-------
|
||||||
|
Thumbnails are saved as .jpg files in the same directory as the source video with the same base filename.
|
||||||
|
Example: video.mp4 → video.jpg
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
-------------
|
||||||
|
- Docker
|
||||||
|
- Videos should be in MP4, MOV, AVI, or MKV format
|
||||||
|
- Sufficient disk space for Docker image (~2GB with YOLO model)
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
------
|
||||||
|
- The first run will download the YOLOv8 model (~6MB) if smart detection is enabled
|
||||||
|
- Processing time depends on video length and smart detection settings
|
||||||
|
- Thumbnails are only generated if they don't already exist (skip existing)
|
14
squashthumbnailmaker/config/.template_info.env
Normal file
14
squashthumbnailmaker/config/.template_info.env
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Template identifier - MUST match the directory name
|
||||||
|
TEMPLATE=squashthumbnailmaker
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
REQUIRES_HOST_ROOT=false # Whether root access on host is needed
|
||||||
|
REQUIRES_DOCKER=true # Whether Docker is required
|
||||||
|
REQUIRES_DOCKER_ROOT=false # Whether Docker root privileges are needed
|
||||||
|
|
||||||
|
# Docker image settings (will be built locally)
|
||||||
|
IMAGE_REGISTRY="local"
|
||||||
|
IMAGE_REPO="squashthumbnailmaker"
|
||||||
|
IMAGE_TAG="latest"
|
||||||
|
|
||||||
|
# This is a utility container, no persistent volumes needed
|
21
squashthumbnailmaker/config/service.env
Normal file
21
squashthumbnailmaker/config/service.env
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Squash Thumbnail Maker Configuration
|
||||||
|
CONTAINER_NAME=squashthumbnailmaker
|
||||||
|
|
||||||
|
# Server settings (REQUIRED by dropshell)
|
||||||
|
SSH_USER="root"
|
||||||
|
|
||||||
|
# Docker image settings
|
||||||
|
IMAGE_REGISTRY="docker.io"
|
||||||
|
IMAGE_REPO="python"
|
||||||
|
IMAGE_TAG="3.11-slim"
|
||||||
|
|
||||||
|
# Processing settings
|
||||||
|
ENABLE_SMART_DETECTION=true # Use YOLO for intelligent thumbnail generation
|
||||||
|
FALLBACK_TIME=10 # Seconds into video for fallback thumbnail
|
||||||
|
THUMBNAIL_WIDTH=640 # Width of generated thumbnails
|
||||||
|
THUMBNAIL_QUALITY=95 # JPEG quality (1-100)
|
||||||
|
MAX_SAMPLE_TIME=180 # Maximum seconds to sample in video
|
||||||
|
SAMPLE_INTERVAL=1 # Interval between samples in seconds
|
||||||
|
|
||||||
|
# Process priority
|
||||||
|
NICE_LEVEL=19 # Process priority (0-19, 19 is lowest)
|
529
squashthumbnailmaker/generate_thumbnail.py
Executable file
529
squashthumbnailmaker/generate_thumbnail.py
Executable file
@@ -0,0 +1,529 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Squash Thumbnail Maker - Generates thumbnails for MP4 videos
|
||||||
|
Uses smart detection to find best frame with players visible
|
||||||
|
Independent standalone version based on squashkiwi approach
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Set process to low priority if configured
|
||||||
|
try:
|
||||||
|
nice_level = int(os.environ.get('NICE_LEVEL', '19'))
|
||||||
|
os.nice(nice_level)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger('thumbnailmaker')
|
||||||
|
|
||||||
|
class ThumbnailGenerator:
|
||||||
|
"""Generates thumbnails for videos using sophisticated AI detection"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.has_yolo = self.check_yolo_available()
|
||||||
|
self.enable_smart = os.environ.get('ENABLE_SMART_DETECTION', 'true').lower() == 'true'
|
||||||
|
self.fallback_time = int(os.environ.get('FALLBACK_TIME', '10'))
|
||||||
|
self.thumbnail_width = int(os.environ.get('THUMBNAIL_WIDTH', '640'))
|
||||||
|
self.thumbnail_quality = int(os.environ.get('THUMBNAIL_QUALITY', '95'))
|
||||||
|
self.max_sample_time = int(os.environ.get('MAX_SAMPLE_TIME', '180'))
|
||||||
|
self.sample_interval = int(os.environ.get('SAMPLE_INTERVAL', '1'))
|
||||||
|
|
||||||
|
def check_yolo_available(self) -> bool:
|
||||||
|
"""Check if YOLOv8 is available for smart detection"""
|
||||||
|
try:
|
||||||
|
from ultralytics import YOLO
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
logger.debug("YOLOv8 not available, will use simple thumbnail generation")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def process_video_file(self, video_file: Path) -> bool:
|
||||||
|
"""Process a single video file"""
|
||||||
|
if not video_file.exists():
|
||||||
|
logger.error(f"Video file not found: {video_file}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if video_file.suffix.lower() not in ['.mp4', '.mov', '.avi', '.mkv']:
|
||||||
|
logger.warning(f"Skipping non-video file: {video_file}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return await self.generate_thumbnail(video_file)
|
||||||
|
|
||||||
|
async def process_directory(self, directory: Path) -> int:
|
||||||
|
"""Process all video files in a directory"""
|
||||||
|
if not directory.exists() or not directory.is_dir():
|
||||||
|
logger.error(f"Directory not found: {directory}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
video_extensions = ['.mp4', '.mov', '.avi', '.mkv']
|
||||||
|
video_files = []
|
||||||
|
|
||||||
|
for ext in video_extensions:
|
||||||
|
video_files.extend(directory.glob(f'*{ext}'))
|
||||||
|
video_files.extend(directory.glob(f'*{ext.upper()}'))
|
||||||
|
|
||||||
|
if not video_files:
|
||||||
|
logger.warning(f"No video files found in {directory}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
logger.info(f"Found {len(video_files)} video files to process")
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
for video_file in video_files:
|
||||||
|
if await self.process_video_file(video_file):
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
return success_count
|
||||||
|
|
||||||
|
async def generate_thumbnail(self, video_file: Path) -> bool:
|
||||||
|
"""Main method to generate thumbnail - tries smart detection first, falls back to simple"""
|
||||||
|
thumbnail_file = video_file.with_suffix('.jpg')
|
||||||
|
|
||||||
|
# Skip if thumbnail already exists
|
||||||
|
if thumbnail_file.exists():
|
||||||
|
logger.info(f"Thumbnail already exists: {thumbnail_file.name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try smart thumbnail generation first if enabled
|
||||||
|
if self.enable_smart and self.has_yolo:
|
||||||
|
try:
|
||||||
|
success = await self.generate_smart_thumbnail(video_file)
|
||||||
|
if success:
|
||||||
|
return True
|
||||||
|
except ImportError as e:
|
||||||
|
logger.debug(f"YOLOv8 not available, using simple thumbnail: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Smart thumbnail failed, using fallback: {e}")
|
||||||
|
|
||||||
|
# Fallback to simple thumbnail at configured time
|
||||||
|
success = await self.generate_simple_thumbnail(video_file, seek_time=self.fallback_time)
|
||||||
|
if success:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Last resort - try to get frame at 1 second
|
||||||
|
success = await self.generate_simple_thumbnail(video_file, seek_time=1)
|
||||||
|
if not success:
|
||||||
|
logger.error(f"All thumbnail generation methods failed for {video_file.name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Critical error generating thumbnail: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def generate_simple_thumbnail(self, video_file: Path, seek_time: int = 10) -> bool:
|
||||||
|
"""Generate a simple thumbnail at specified time"""
|
||||||
|
try:
|
||||||
|
thumbnail_file = video_file.with_suffix('.jpg')
|
||||||
|
logger.debug(f"Attempting simple thumbnail generation for {video_file.name} at {seek_time}s")
|
||||||
|
|
||||||
|
if not video_file.exists():
|
||||||
|
logger.error(f"Video file does not exist: {video_file}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
video_size = video_file.stat().st_size
|
||||||
|
logger.debug(f"Video file size: {video_size / (1024*1024):.1f} MB")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'ffmpeg',
|
||||||
|
'-i', str(video_file),
|
||||||
|
'-ss', str(seek_time),
|
||||||
|
'-vframes', '1',
|
||||||
|
'-vf', f'scale={self.thumbnail_width}:-1',
|
||||||
|
'-q:v', str(100 - self.thumbnail_quality + 1), # Convert quality to ffmpeg scale
|
||||||
|
'-y',
|
||||||
|
str(thumbnail_file)
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.debug(f"Running FFmpeg command: {' '.join(cmd[:6])}")
|
||||||
|
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
|
||||||
|
if process.returncode == 0:
|
||||||
|
if thumbnail_file.exists():
|
||||||
|
thumb_size = thumbnail_file.stat().st_size
|
||||||
|
logger.info(f"Generated simple thumbnail: {thumbnail_file.name} at {seek_time}s ({thumb_size / 1024:.1f} KB)")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"FFmpeg succeeded but thumbnail file not created: {thumbnail_file}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.error(f"FFmpeg failed with code {process.returncode} for {video_file.name}")
|
||||||
|
if stderr:
|
||||||
|
stderr_text = stderr.decode('utf-8', errors='ignore')[-500:]
|
||||||
|
logger.error(f"FFmpeg stderr: {stderr_text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Exception generating simple thumbnail for {video_file.name}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def generate_smart_thumbnail(self, video_file: Path) -> bool:
|
||||||
|
"""Generate thumbnail using YOLOv8 pose model to find best frame with both players"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Starting smart thumbnail generation for {video_file.name}")
|
||||||
|
|
||||||
|
# Import here to avoid loading if not needed
|
||||||
|
from ultralytics import YOLO
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
thumbnail_file = video_file.with_suffix('.jpg')
|
||||||
|
|
||||||
|
# Load YOLO pose model for better player detection
|
||||||
|
logger.debug("Loading YOLOv8 pose model...")
|
||||||
|
model = YOLO('yolov8n-pose.pt')
|
||||||
|
logger.debug("YOLOv8 model loaded successfully")
|
||||||
|
|
||||||
|
# Sample frames from video
|
||||||
|
sample_times = list(range(1, min(self.max_sample_time + 1, 181), self.sample_interval))
|
||||||
|
logger.debug(f"Will sample {len(sample_times)} frames from video")
|
||||||
|
|
||||||
|
# Get video dimensions first
|
||||||
|
probe_cmd = [
|
||||||
|
'ffprobe', '-v', 'error',
|
||||||
|
'-select_streams', 'v:0',
|
||||||
|
'-show_entries', 'stream=width,height',
|
||||||
|
'-of', 'csv=s=x:p=0',
|
||||||
|
str(video_file)
|
||||||
|
]
|
||||||
|
|
||||||
|
probe_process = await asyncio.create_subprocess_exec(
|
||||||
|
*probe_cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
|
||||||
|
dimensions, _ = await probe_process.communicate()
|
||||||
|
|
||||||
|
if not dimensions:
|
||||||
|
logger.error("Failed to get video dimensions")
|
||||||
|
return False
|
||||||
|
|
||||||
|
width, height = map(int, dimensions.decode().strip().split('x'))
|
||||||
|
logger.debug(f"Video dimensions: {width}x{height}")
|
||||||
|
|
||||||
|
best_frame = None
|
||||||
|
best_score = 0
|
||||||
|
best_time = self.fallback_time
|
||||||
|
frames_processed = 0
|
||||||
|
|
||||||
|
# First pass: sample frames
|
||||||
|
for seek_time in sample_times:
|
||||||
|
logger.debug(f"Extracting frame at {seek_time}s...")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'ffmpeg',
|
||||||
|
'-ss', str(seek_time),
|
||||||
|
'-i', str(video_file),
|
||||||
|
'-vframes', '1',
|
||||||
|
'-f', 'image2pipe',
|
||||||
|
'-pix_fmt', 'rgb24',
|
||||||
|
'-vcodec', 'rawvideo',
|
||||||
|
'-'
|
||||||
|
]
|
||||||
|
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, _ = await process.communicate()
|
||||||
|
|
||||||
|
if process.returncode != 0 or not stdout:
|
||||||
|
logger.debug(f"Failed to extract frame at {seek_time}s")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert raw video to numpy array
|
||||||
|
frame = np.frombuffer(stdout, dtype=np.uint8)
|
||||||
|
frame = frame.reshape((height, width, 3))
|
||||||
|
frames_processed += 1
|
||||||
|
|
||||||
|
# Run YOLO detection
|
||||||
|
logger.debug(f"Running YOLO detection on frame at {seek_time}s...")
|
||||||
|
results = model(frame, verbose=False, conf=0.3)
|
||||||
|
|
||||||
|
# Calculate score for this frame
|
||||||
|
score = self.calculate_frame_score(results, width, height)
|
||||||
|
logger.debug(f"Frame at {seek_time}s scored: {score}")
|
||||||
|
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_frame = frame
|
||||||
|
best_time = seek_time
|
||||||
|
logger.info(f"New best frame found at {seek_time}s with score {score}")
|
||||||
|
|
||||||
|
# If we find an excellent frame, stop searching
|
||||||
|
if score >= 180:
|
||||||
|
logger.info(f"Excellent frame found at {seek_time}s! Stopping search.")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Second pass: fine-grained search around the best frame
|
||||||
|
if best_frame is not None and best_score > 50:
|
||||||
|
logger.info(f"Found initial best frame at {best_time}s with score {best_score}")
|
||||||
|
logger.info(f"Performing fine-tuning search ±0.9s around best frame...")
|
||||||
|
|
||||||
|
fine_sample_times = []
|
||||||
|
for offset in range(-9, 10):
|
||||||
|
if offset == 0:
|
||||||
|
continue
|
||||||
|
fine_time = best_time + (offset * 0.1)
|
||||||
|
if fine_time > 0:
|
||||||
|
fine_sample_times.append(fine_time)
|
||||||
|
|
||||||
|
logger.info(f"Fine-tuning: checking {len(fine_sample_times)} frames around {best_time}s")
|
||||||
|
|
||||||
|
for seek_time in fine_sample_times:
|
||||||
|
logger.debug(f"Fine-tuning: checking frame at {seek_time:.1f}s...")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'ffmpeg',
|
||||||
|
'-ss', str(seek_time),
|
||||||
|
'-i', str(video_file),
|
||||||
|
'-vframes', '1',
|
||||||
|
'-f', 'image2pipe',
|
||||||
|
'-pix_fmt', 'rgb24',
|
||||||
|
'-vcodec', 'rawvideo',
|
||||||
|
'-'
|
||||||
|
]
|
||||||
|
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, _ = await process.communicate()
|
||||||
|
|
||||||
|
if process.returncode != 0 or not stdout:
|
||||||
|
logger.debug(f"Failed to extract frame at {seek_time:.1f}s")
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame = np.frombuffer(stdout, dtype=np.uint8)
|
||||||
|
frame = frame.reshape((height, width, 3))
|
||||||
|
frames_processed += 1
|
||||||
|
|
||||||
|
results = model(frame, verbose=False, conf=0.3)
|
||||||
|
score = self.calculate_frame_score(results, width, height)
|
||||||
|
logger.debug(f"Fine-tuning: frame at {seek_time:.1f}s scored: {score}")
|
||||||
|
|
||||||
|
if score > best_score:
|
||||||
|
logger.info(f"Fine-tuning: found better frame at {seek_time:.1f}s with score {score}")
|
||||||
|
best_score = score
|
||||||
|
best_frame = frame
|
||||||
|
best_time = seek_time
|
||||||
|
|
||||||
|
if score >= 180:
|
||||||
|
logger.info(f"Fine-tuning: excellent frame found at {seek_time:.1f}s!")
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(f"Fine-tuning complete. Final best frame at {best_time}s with score {best_score}")
|
||||||
|
|
||||||
|
# Save the best frame if we found one
|
||||||
|
logger.info(f"Processed {frames_processed} frames total")
|
||||||
|
if best_frame is not None and best_score > 0:
|
||||||
|
logger.info(f"Saving final best frame (score {best_score}) from {best_time}s as thumbnail")
|
||||||
|
|
||||||
|
# Convert RGB to BGR for OpenCV
|
||||||
|
best_frame_bgr = cv2.cvtColor(best_frame, cv2.COLOR_RGB2BGR)
|
||||||
|
|
||||||
|
# Resize to configured width
|
||||||
|
height, width = best_frame_bgr.shape[:2]
|
||||||
|
new_width = self.thumbnail_width
|
||||||
|
new_height = int(height * (new_width / width))
|
||||||
|
resized = cv2.resize(best_frame_bgr, (new_width, new_height), interpolation=cv2.INTER_AREA)
|
||||||
|
logger.debug(f"Resized thumbnail to {new_width}x{new_height}")
|
||||||
|
|
||||||
|
# Save as JPEG with configured quality
|
||||||
|
cv2.imwrite(str(thumbnail_file), resized, [cv2.IMWRITE_JPEG_QUALITY, self.thumbnail_quality])
|
||||||
|
|
||||||
|
if thumbnail_file.exists():
|
||||||
|
thumb_size = thumbnail_file.stat().st_size
|
||||||
|
logger.info(f"Smart thumbnail saved: {thumbnail_file.name} ({thumb_size / 1024:.1f} KB) from {best_time}s with score {best_score}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to save smart thumbnail to {thumbnail_file}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.warning(f"No suitable frame found for smart thumbnail (best score was {best_score})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except ImportError as e:
|
||||||
|
logger.debug(f"YOLOv8 not installed: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Smart thumbnail generation error: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def calculate_frame_score(self, results, frame_width, frame_height) -> int:
|
||||||
|
"""Calculate score for a frame based on player detection and pose"""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
if not results or len(results) == 0:
|
||||||
|
logger.debug("No detection results")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
result = results[0]
|
||||||
|
|
||||||
|
# Get person detections
|
||||||
|
if result.boxes is None:
|
||||||
|
logger.debug("No boxes detected")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
persons = []
|
||||||
|
for box in result.boxes:
|
||||||
|
if box.cls == 0: # Class 0 is person in COCO
|
||||||
|
persons.append(box)
|
||||||
|
|
||||||
|
logger.debug(f"Detected {len(persons)} person(s) in frame")
|
||||||
|
|
||||||
|
# Score based on number of people
|
||||||
|
if len(persons) == 2:
|
||||||
|
score = 30 # Base score for having both players
|
||||||
|
logger.debug("Both players detected: +30 points")
|
||||||
|
elif len(persons) == 1:
|
||||||
|
score = 5 # Low score for single player
|
||||||
|
logger.debug("Single player detected: +5 points")
|
||||||
|
else:
|
||||||
|
logger.debug(f"No valid player count ({len(persons)} detected)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Check poses if available for face visibility
|
||||||
|
if hasattr(result, 'keypoints') and result.keypoints is not None:
|
||||||
|
keypoints = result.keypoints.xy.cpu().numpy() if hasattr(result.keypoints.xy, 'cpu') else result.keypoints.xy
|
||||||
|
|
||||||
|
faces_visible = 0
|
||||||
|
for person_kpts in keypoints[:2]: # Check first 2 people
|
||||||
|
if len(person_kpts) > 0:
|
||||||
|
# Check if face keypoints are visible (indices 0-4 are face in COCO pose)
|
||||||
|
face_points = person_kpts[:5]
|
||||||
|
face_visible = np.sum(face_points[:, 0] > 0) >= 3 # At least 3 face points visible
|
||||||
|
|
||||||
|
if face_visible:
|
||||||
|
faces_visible += 1
|
||||||
|
|
||||||
|
# Bonus points for faces visible
|
||||||
|
if faces_visible == 2:
|
||||||
|
score += 100 # Both faces clearly visible
|
||||||
|
logger.debug("Both faces visible: +100 points")
|
||||||
|
elif faces_visible == 1:
|
||||||
|
score += 20 # One face visible
|
||||||
|
logger.debug("One face visible: +20 points")
|
||||||
|
|
||||||
|
# Additional scoring based on player size and positioning
|
||||||
|
if len(persons) == 2:
|
||||||
|
center_players = 0
|
||||||
|
total_player_area = 0
|
||||||
|
min_player_area = float('inf')
|
||||||
|
|
||||||
|
for box in persons[:2]:
|
||||||
|
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||||
|
center_x = (x1 + x2) / 2
|
||||||
|
|
||||||
|
# Calculate player bounding box area as percentage of frame
|
||||||
|
box_width = x2 - x1
|
||||||
|
box_height = y2 - y1
|
||||||
|
box_area = (box_width * box_height) / (frame_width * frame_height)
|
||||||
|
total_player_area += box_area
|
||||||
|
min_player_area = min(min_player_area, box_area)
|
||||||
|
|
||||||
|
# Check if player is in central 60% of frame
|
||||||
|
if 0.2 * frame_width < center_x < 0.8 * frame_width:
|
||||||
|
center_players += 1
|
||||||
|
|
||||||
|
# Score based on player size
|
||||||
|
avg_player_area = total_player_area / 2
|
||||||
|
|
||||||
|
if avg_player_area > 0.08:
|
||||||
|
score += 40
|
||||||
|
logger.debug(f"Players are large ({avg_player_area*100:.1f}% avg): +40 points")
|
||||||
|
elif avg_player_area > 0.05:
|
||||||
|
score += 25
|
||||||
|
logger.debug(f"Players are good size ({avg_player_area*100:.1f}% avg): +25 points")
|
||||||
|
elif avg_player_area > 0.03:
|
||||||
|
score += 10
|
||||||
|
logger.debug(f"Players are small ({avg_player_area*100:.1f}% avg): +10 points")
|
||||||
|
|
||||||
|
# Both players should be similar size
|
||||||
|
if min_player_area > 0:
|
||||||
|
size_ratio = min_player_area / (total_player_area - min_player_area)
|
||||||
|
if size_ratio > 0.5:
|
||||||
|
score += 10
|
||||||
|
logger.debug(f"Players are similar size (ratio {size_ratio:.2f}): +10 points")
|
||||||
|
|
||||||
|
# Bonus for both players being centered
|
||||||
|
if center_players == 2:
|
||||||
|
score += 15
|
||||||
|
logger.debug("Both players centered: +15 points")
|
||||||
|
elif center_players == 1:
|
||||||
|
score += 5
|
||||||
|
logger.debug("One player centered: +5 points")
|
||||||
|
|
||||||
|
logger.debug(f"Final frame score: {score}")
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Main entry point"""
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
logger.error("Usage: generate_thumbnail.py <video_file_or_directory>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
input_path = Path(sys.argv[1])
|
||||||
|
|
||||||
|
# Report nice level
|
||||||
|
try:
|
||||||
|
nice_level = os.nice(0) # Get current nice level
|
||||||
|
logger.info(f"Running at nice level: {nice_level} (low priority)")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
generator = ThumbnailGenerator()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if input_path.is_file():
|
||||||
|
# Process single file
|
||||||
|
if await generator.process_video_file(input_path):
|
||||||
|
logger.info(f"Thumbnail generation successful for {input_path.name}")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
logger.error(f"Thumbnail generation failed for {input_path.name}")
|
||||||
|
sys.exit(1)
|
||||||
|
elif input_path.is_dir():
|
||||||
|
# Process directory
|
||||||
|
success_count = await generator.process_directory(input_path)
|
||||||
|
total_count = len(list(input_path.glob('*.mp4')) + list(input_path.glob('*.MP4')))
|
||||||
|
logger.info(f"Processed {success_count}/{total_count} videos successfully")
|
||||||
|
sys.exit(0 if success_count > 0 else 1)
|
||||||
|
else:
|
||||||
|
logger.error(f"Invalid input: {input_path} is neither a file nor a directory")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
asyncio.run(main())
|
23
squashthumbnailmaker/install.sh
Executable file
23
squashthumbnailmaker/install.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
source "${AGENT_PATH}/common.sh"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# Check required environment variables
|
||||||
|
_check_required_env_vars "CONTAINER_NAME" "IMAGE_REGISTRY" "IMAGE_REPO" "IMAGE_TAG"
|
||||||
|
|
||||||
|
# Check Docker is available
|
||||||
|
_check_docker_installed || _die "Docker test failed"
|
||||||
|
|
||||||
|
echo "Building squashthumbnailmaker Docker image..."
|
||||||
|
|
||||||
|
# Build the Docker image locally
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
docker build -t "${CONTAINER_NAME}_image:latest" . || _die "Failed to build Docker image"
|
||||||
|
|
||||||
|
echo "Installation of ${CONTAINER_NAME} complete"
|
||||||
|
echo ""
|
||||||
|
echo "Usage:"
|
||||||
|
echo " Process single video: docker run --rm -v /path/to/videos:/data ${CONTAINER_NAME}_image:latest /data/video.mp4"
|
||||||
|
echo " Process entire directory: docker run --rm -v /path/to/videos:/data ${CONTAINER_NAME}_image:latest /data"
|
||||||
|
echo ""
|
||||||
|
echo "The thumbnail will be created as video.jpg in the same directory as the video file."
|
14
squashthumbnailmaker/logs.sh
Executable file
14
squashthumbnailmaker/logs.sh
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
source "${AGENT_PATH}/common.sh"
|
||||||
|
_check_required_env_vars "CONTAINER_NAME"
|
||||||
|
|
||||||
|
# Show logs from any containers running with this image
|
||||||
|
containers=$(docker ps -a --filter "ancestor=${CONTAINER_NAME}_image:latest" --format "{{.ID}}")
|
||||||
|
if [ -n "$containers" ]; then
|
||||||
|
for container in $containers; do
|
||||||
|
echo "=== Logs from container $container ==="
|
||||||
|
docker logs "$container" "$@"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "No containers found for ${CONTAINER_NAME}"
|
||||||
|
fi
|
70
squashthumbnailmaker/process_videos.sh
Executable file
70
squashthumbnailmaker/process_videos.sh
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Helper script for easier local usage of the thumbnail maker
|
||||||
|
|
||||||
|
# Check if Docker image exists
|
||||||
|
if ! docker images --format "{{.Repository}}:{{.Tag}}" | grep -q "^squashthumbnailmaker_image:latest$"; then
|
||||||
|
echo "Docker image not found. Please run install.sh first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check arguments
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "Usage: $0 <video_file_or_directory>"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 /path/to/video.mp4 # Process single video"
|
||||||
|
echo " $0 /path/to/videos/ # Process all videos in directory"
|
||||||
|
echo ""
|
||||||
|
echo "Options (set as environment variables):"
|
||||||
|
echo " ENABLE_SMART_DETECTION=false # Disable YOLO detection"
|
||||||
|
echo " THUMBNAIL_WIDTH=1280 # Change thumbnail width"
|
||||||
|
echo " THUMBNAIL_QUALITY=90 # Change JPEG quality"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
INPUT_PATH="$1"
|
||||||
|
|
||||||
|
# Get absolute path
|
||||||
|
INPUT_PATH=$(realpath "$INPUT_PATH")
|
||||||
|
|
||||||
|
if [ ! -e "$INPUT_PATH" ]; then
|
||||||
|
echo "Error: $INPUT_PATH does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine if input is file or directory and get the mount path
|
||||||
|
if [ -f "$INPUT_PATH" ]; then
|
||||||
|
# For a file, mount its directory and reference the file
|
||||||
|
MOUNT_DIR=$(dirname "$INPUT_PATH")
|
||||||
|
FILENAME=$(basename "$INPUT_PATH")
|
||||||
|
CONTAINER_PATH="/data/$FILENAME"
|
||||||
|
elif [ -d "$INPUT_PATH" ]; then
|
||||||
|
# For a directory, mount it directly
|
||||||
|
MOUNT_DIR="$INPUT_PATH"
|
||||||
|
CONTAINER_PATH="/data"
|
||||||
|
else
|
||||||
|
echo "Error: $INPUT_PATH is neither a file nor a directory"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build Docker run command
|
||||||
|
DOCKER_CMD="docker run --rm"
|
||||||
|
|
||||||
|
# Add environment variables if set
|
||||||
|
[ -n "$ENABLE_SMART_DETECTION" ] && DOCKER_CMD="$DOCKER_CMD -e ENABLE_SMART_DETECTION=$ENABLE_SMART_DETECTION"
|
||||||
|
[ -n "$FALLBACK_TIME" ] && DOCKER_CMD="$DOCKER_CMD -e FALLBACK_TIME=$FALLBACK_TIME"
|
||||||
|
[ -n "$THUMBNAIL_WIDTH" ] && DOCKER_CMD="$DOCKER_CMD -e THUMBNAIL_WIDTH=$THUMBNAIL_WIDTH"
|
||||||
|
[ -n "$THUMBNAIL_QUALITY" ] && DOCKER_CMD="$DOCKER_CMD -e THUMBNAIL_QUALITY=$THUMBNAIL_QUALITY"
|
||||||
|
[ -n "$MAX_SAMPLE_TIME" ] && DOCKER_CMD="$DOCKER_CMD -e MAX_SAMPLE_TIME=$MAX_SAMPLE_TIME"
|
||||||
|
[ -n "$SAMPLE_INTERVAL" ] && DOCKER_CMD="$DOCKER_CMD -e SAMPLE_INTERVAL=$SAMPLE_INTERVAL"
|
||||||
|
[ -n "$NICE_LEVEL" ] && DOCKER_CMD="$DOCKER_CMD -e NICE_LEVEL=$NICE_LEVEL"
|
||||||
|
|
||||||
|
# Add volume mount and run
|
||||||
|
DOCKER_CMD="$DOCKER_CMD -v \"$MOUNT_DIR:/data\" squashthumbnailmaker_image:latest \"$CONTAINER_PATH\""
|
||||||
|
|
||||||
|
echo "Processing: $INPUT_PATH"
|
||||||
|
echo "Running: $DOCKER_CMD"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Execute the command
|
||||||
|
eval $DOCKER_CMD
|
15
squashthumbnailmaker/start.sh
Executable file
15
squashthumbnailmaker/start.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
source "${AGENT_PATH}/common.sh"
|
||||||
|
_check_required_env_vars "CONTAINER_NAME"
|
||||||
|
|
||||||
|
echo "This is a utility container that runs on-demand."
|
||||||
|
echo "To process videos, use:"
|
||||||
|
echo ""
|
||||||
|
echo " Single video: docker run --rm -v /path/to/videos:/data ${CONTAINER_NAME}_image:latest /data/video.mp4"
|
||||||
|
echo " All videos in dir: docker run --rm -v /path/to/videos:/data ${CONTAINER_NAME}_image:latest /data"
|
||||||
|
echo ""
|
||||||
|
echo "Environment variables can be passed with -e flag:"
|
||||||
|
echo " -e ENABLE_SMART_DETECTION=false # Disable YOLO detection"
|
||||||
|
echo " -e FALLBACK_TIME=5 # Change fallback thumbnail time"
|
||||||
|
echo " -e THUMBNAIL_WIDTH=1280 # Change thumbnail width"
|
||||||
|
echo " -e THUMBNAIL_QUALITY=90 # Change JPEG quality"
|
15
squashthumbnailmaker/status.sh
Executable file
15
squashthumbnailmaker/status.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
source "${AGENT_PATH}/common.sh"
|
||||||
|
_check_required_env_vars "CONTAINER_NAME"
|
||||||
|
|
||||||
|
# Check if the image exists
|
||||||
|
if docker images --format "{{.Repository}}:{{.Tag}}" | grep -q "^${CONTAINER_NAME}_image:latest$"; then
|
||||||
|
# Check if any containers are currently running with this image
|
||||||
|
if docker ps --filter "ancestor=${CONTAINER_NAME}_image:latest" --format "{{.ID}}" | grep -q .; then
|
||||||
|
echo "Running"
|
||||||
|
else
|
||||||
|
echo "Stopped"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Unknown"
|
||||||
|
fi
|
8
squashthumbnailmaker/stop.sh
Executable file
8
squashthumbnailmaker/stop.sh
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
source "${AGENT_PATH}/common.sh"
|
||||||
|
_check_required_env_vars "CONTAINER_NAME"
|
||||||
|
|
||||||
|
# Stop any running containers using this image
|
||||||
|
docker ps --filter "ancestor=${CONTAINER_NAME}_image:latest" --format "{{.ID}}" | xargs -r docker stop 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Stopped any running ${CONTAINER_NAME} containers"
|
11
squashthumbnailmaker/uninstall.sh
Executable file
11
squashthumbnailmaker/uninstall.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
source "${AGENT_PATH}/common.sh"
|
||||||
|
_check_required_env_vars "CONTAINER_NAME"
|
||||||
|
|
||||||
|
# Remove any running containers using this image
|
||||||
|
docker ps -a --filter "ancestor=${CONTAINER_NAME}_image:latest" --format "{{.ID}}" | xargs -r docker rm -f 2>/dev/null || true
|
||||||
|
|
||||||
|
# Remove the Docker image
|
||||||
|
docker rmi "${CONTAINER_NAME}_image:latest" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Uninstallation of ${CONTAINER_NAME} complete"
|
Reference in New Issue
Block a user