118 lines
3.5 KiB
Python
118 lines
3.5 KiB
Python
"""
|
|
VIDEO MOSAIC MAKER
|
|
==================
|
|
Grabs 9 equally spaced frames from a video and returns a 1080p mosaic image,
|
|
with the video timestamp of each frame stamped in its upper-left corner.
|
|
|
|
Requirements:
|
|
pip install opencv-python Pillow
|
|
"""
|
|
|
|
import cv2
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
|
|
def _format_timestamp(seconds):
|
|
"""Formats a number of seconds as HH:MM:SS (or MM:SS if under an hour)."""
|
|
total_seconds = int(seconds)
|
|
hours, remainder = divmod(total_seconds, 3600)
|
|
minutes, secs = divmod(remainder, 60)
|
|
if hours:
|
|
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
|
return f"{minutes:02d}:{secs:02d}"
|
|
|
|
|
|
def _load_font(size):
|
|
"""Tries to load a truetype font, falling back to PIL's default bitmap font."""
|
|
for font_name in ("DejaVuSans-Bold.ttf", "Arial.ttf", "arial.ttf"):
|
|
try:
|
|
return ImageFont.truetype(font_name, size)
|
|
except IOError:
|
|
continue
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _draw_timestamp(image, text, margin=10):
|
|
"""Draws `text` in the upper-left corner of `image` with a dark backing
|
|
box behind it so it stays readable over any frame content."""
|
|
draw = ImageDraw.Draw(image, "RGBA")
|
|
|
|
font_size = max(16, image.height // 20)
|
|
font = _load_font(font_size)
|
|
|
|
bbox = draw.textbbox((0, 0), text, font=font)
|
|
text_w = bbox[2] - bbox[0]
|
|
text_h = bbox[3] - bbox[1]
|
|
|
|
box_x0, box_y0 = margin, margin
|
|
box_x1 = box_x0 + text_w + 2 * margin
|
|
box_y1 = box_y0 + text_h + 2 * margin
|
|
draw.rectangle([box_x0, box_y0, box_x1, box_y1], fill=(0, 0, 0, 160))
|
|
|
|
draw.text(
|
|
(box_x0 + margin - bbox[0], box_y0 + margin - bbox[1]),
|
|
text,
|
|
font=font,
|
|
fill=(255, 255, 255, 255),
|
|
)
|
|
return image
|
|
|
|
|
|
def video_mosaic(video_path):
|
|
"""
|
|
Takes a video file path and returns a 1920x1080 PIL image
|
|
with 9 frames arranged in a 3x3 grid, each stamped with its
|
|
timestamp in the video.
|
|
"""
|
|
|
|
# --- Open the video ---
|
|
video = cv2.VideoCapture(video_path)
|
|
total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
fps = video.get(cv2.CAP_PROP_FPS) or 30.0 # fallback if fps is unreadable
|
|
|
|
# --- Calculate which 9 frames to grab (evenly spaced) ---
|
|
frame_positions = [
|
|
int(total_frames * (i + 1) / 10) # 10%, 20%, ... 90%
|
|
for i in range(9)
|
|
]
|
|
|
|
# --- Extract each frame, paired with its timestamp ---
|
|
frames = []
|
|
for pos in frame_positions:
|
|
video.set(cv2.CAP_PROP_POS_FRAMES, pos)
|
|
success, frame = video.read()
|
|
if success:
|
|
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # BGR → RGB
|
|
timestamp = _format_timestamp(pos / fps)
|
|
frames.append((Image.fromarray(frame_rgb), timestamp))
|
|
|
|
video.release()
|
|
|
|
# --- Build the 1920x1080 mosaic ---
|
|
COLS, ROWS = 3, 3
|
|
PADDING = 10
|
|
CANVAS_W = 1920
|
|
CANVAS_H = 1080
|
|
cell_w = (CANVAS_W - PADDING * (COLS + 1)) // COLS
|
|
cell_h = (CANVAS_H - PADDING * (ROWS + 1)) // ROWS
|
|
|
|
mosaic = Image.new("RGB", (CANVAS_W, CANVAS_H), color=(0, 0, 0))
|
|
|
|
for i, (frame, timestamp) in enumerate(frames):
|
|
row, col = divmod(i, COLS)
|
|
x = PADDING + col * (cell_w + PADDING)
|
|
y = PADDING + row * (cell_h + PADDING)
|
|
|
|
resized = frame.resize((cell_w, cell_h), Image.LANCZOS)
|
|
_draw_timestamp(resized, timestamp)
|
|
|
|
mosaic.paste(resized, (x, y))
|
|
|
|
return mosaic
|
|
|
|
|
|
# --- Example usage ---
|
|
if __name__ == "__main__":
|
|
image = video_mosaic("video.mp4")
|
|
image.save("mosaic.jpg")
|