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