Just came home from a vacation, and I have a bunch of GoPro videos that I want to compile into a fun music video. In order to support this process, I wanted to write a script that would take all my videos and create a 'summary' thumbnail collage for each video. The idea is that it'll allow me to skim through my videos more quickly than manually skipping through the videos.
This generated image should contain the following:
- A 'main' thumbnail (covering the majority of the screen). This is taken at the middle of the video.
- A grid of smaller thumbnails surrounding this main thumbnail, showcasing the various shots found throughout the video (in a clockwise, chronological order)
Here's an example of my output:
As I am starting out with Python, I figured this would be a fun mini-project. The python script I wrote to generate this image for each of the videos in a given folder is as follows, I'd very much appreciate some feedback on the script!
I've succesfully used it on my Windows machine, Python 2.7, ffmpeg needs to be installed and in your path variable (i.e. 'ffmpeg.exe' in the command prompt should start ffmpeg).
from __future__ import division
""" Module to generate a single image for each GoPro video that consists
of a grid of thumbnails with a larger centered thumbnail.
"""
import datetime
import logging
import math
import os
import re
import subprocess
import sys
import time
from collections import namedtuple
from PIL import Image # Install as 'Pillow'
class ZeroDurationError(ZeroDivisionError):
pass
class ffmpegError(IOError):
pass
def generate_logger(log_path=None, ignore_modules=None, log_level=logging.INFO):
""" Returns a logger that logs both in console and in a file.
If log_path is omitted, logger.log is placed in the current working directory.
Module names in 'ignore_modules' are reduced to .WARNING log messages """
if ignore_modules == None:
ignore_modules = []
for module in ignore_modules:
logging.getLogger(module).setLevel(logging.WARNING)
log = logging.getLogger('')
log.setLevel(log_level)
format = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
ch = logging.StreamHandler(sys.stdout)
ch.setFormatter(format)
log.addHandler(ch)
if log_path is None:
fh = logging.FileHandler("logger.log")
else:
fh = logging.FileHandler(log_path)
fh.setFormatter(format)
log.addHandler(fh)
return log
def get_video_duration(file_path):
""" For a given file_path to a video, return its duration in seconds"""
ff_call = 'ffmpeg -i "{}" 2>&1'.format(file_path)
pattern = r"Duration: (\d\d):(\d\d):(\d\d(\.\d\d)?)"
output = subprocess.Popen(ff_call,
shell=True,
stdout=subprocess.PIPE).stdout.read()
duration_str = re.search(pattern, output).group()
duration_str = re.search("\d{2}:\d{2}:\d{2}\.\d{2}", duration_str).group()
duration = datetime.datetime.strptime(duration_str, "%H:%M:%S.%f")
zero = datetime.datetime.strptime("00:00:00.00", "%H:%M:%S.%f")
return (duration - zero).seconds
def get_thumbnail_times(duration, n):
""" Given a duration in seconds and n, obtain ffmpeg timestamps
at which point thumbnails should be generated to evenly obtain
n thumbnails over its duration """
if duration == 0:
raise ZeroDurationError("Duration may not be zero")
step_size = duration / (n - 1)
time_in_seconds = [x * step_size for x in range(0, 1 + int(math.ceil(duration / step_size)))]
zero = datetime.datetime.strptime("00:00:00.00", "%H:%M:%S.%f")
timestamps = []
for offset in time_in_seconds:
milli = 1000 * (offset - int(offset))
thumbnail_time = zero + datetime.timedelta(seconds=int(offset),
milliseconds=int(milli))
timestamps.append(thumbnail_time.strftime("%H:%M:%S.%f"))
return timestamps
def generate_thumbnail(file_path, target_filename, ffmpeg_time):
""" Given a video 'file_path', generate a thumbnail 'target_filename' at time 'ffmpeg_time'. Returns ffmpegError if no ffmpeg caused an error or thumbnail was not created, returns the target_filename. """
ff_call = "".join(["ffmpeg ",
"-accurate_seek -ss ", ffmpeg_time,
" -i ", '"', file_path, '"',
" -s hd1080 -frames:v 1 ",
'"', target_filename, '"'])
# Suppress ffmpeg output, redirect output to fnull
with open(os.devnull, 'w') as fnull:
process = subprocess.Popen(ff_call,
shell=True,
stdout=fnull,
stderr=subprocess.STDOUT)
process.wait()
if not os.path.isfile(target_filename):
raise ffmpegError("Resulting thumbnail file not found: {}".format(target_filename))
return target_filename
def get_thumbnail_filenames(file_path, n):
""" Given a video 'file_path' and n, generate n filepaths that can be used for storing
thumbnails (video TEST.MP4 will have TEST__Thumb00X.JPG) """
filename, ext = os.path.splitext(file_path)
thumbnail_paths = []
for x in range(1, n+1):
target = filename + "__Thumb{0:02d}".format(x)
target = target + ".JPG"
thumbnail_paths.append(target)
return thumbnail_paths
def generate_thumbnails(file_path, n=10, overwrite=False):
""" Given a video file_path, generate n (default 10) thumbnails.
By default, existing thumbnails are reused, use overwrite=True to change
this behaviour. """
duration = get_video_duration(file_path)
if duration == 0:
raise ZeroDurationError("Video duration is zero?")
thumbnail_times = get_thumbnail_times(duration, n)
thumbnails_to_create = get_thumbnail_filenames(file_path, n)
n_generated = 0
log.debug("Generating thumbnails for {}".format(os.path.basename(file_path)))
for i, (target_path, ffmpeg_time) in enumerate(zip(thumbnails_to_create, thumbnail_times)):
if i % 5 == 0 and i != 0:
log.debug("Generated thumbnails ({}/{}".format(i, len(thumbnails_to_create)))
log.debug("Generating thumbnail {} at time {}".format(os.path.basename(target_path), ffmpeg_time))
if overwrite and os.path.isfile(target_path):
os.remove(target_path)
elif os.path.isfile(target_path):
n_generated += 1
continue
try:
generate_thumbnail(file_path=file_path,
target_filename=target_path,
ffmpeg_time=ffmpeg_time)
n_generated += 1
except ffmpegError as e:
if n_generated > 1: # Script will later reuse previous thumbnail if at least one is created
continue
else:
raise ffmpegError("Cannot generate thumbnails for given file: {}".format(os.path.basename(file_path)))
return zip(thumbnails_to_create, thumbnail_times)
def generate_thumbnail_grid(img_width=1920,
img_height=1080,
horizontal_n=8,
whitespace=0.1):
""" Generate a grid on which thumbnails can be placed
Requires horizontal_n and the horizontal proportion (of width) whitespace between each thumbnail,
the number of vertical thumbnails are automatically calculated utilizing the total result
img_width / whitespace and number of images, combined with its aspect ratio.
Returns a grid as a list of rows, each row is a list of tuples
(cell.row, cell.column, # Row number / Column number
cell.x_coord, cell.y_coord) # Topleft coordinates
"""
small_width = int(img_width / ((1 + whitespace) * horizontal_n))
small_height = int(9 / 16 * small_width)
small_size = namedtuple("small_size", ['width', 'height'])
small_size = small_size(width=small_width, height=small_height)
internal_whitespace = int(small_height * whitespace)
# Determine number of vertical images in grid
vertical_n = int(img_height /
(small_height + internal_whitespace))
test_height = vertical_n * (small_height) + (vertical_n - 1) * internal_whitespace
if test_height > img_height:
vertical_n -= 1
width_images = horizontal_n * small_width
width_whitespace = (horizontal_n - 1) * internal_whitespace
left_external_whitespace = int((img_width - width_images - width_whitespace) / 2)
height_images = vertical_n * small_height
height_whitespace = (vertical_n - 1) * internal_whitespace
top_external_whitespace = int((img_height - height_images - height_whitespace) / 2)
grid = []
Cell = namedtuple("grid", ['row', 'column', 'x_coord', 'y_coord'])
# First create the grid as one long list
for y in range(0, vertical_n):
for x in range(0, horizontal_n):
x_coordinate = left_external_whitespace + \
x * (internal_whitespace + small_width)
y_coordinate = top_external_whitespace + \
y * (internal_whitespace + small_height)
grid.append(Cell(row=y + 1,
column=x + 1,
x_coord=x_coordinate,
y_coord=y_coordinate))
# Change the grid to a list of lists (each sub-list is a row)
grid = [grid[i:i+horizontal_n] for i in range(0, len(grid), horizontal_n)]
return grid, small_size
def get_clockwise_outer_grid(grid):
""" Given a grid containing instances of Cell with a .row and .column attribute,
return the outer ring (sorted clockwise) row / column numbers
"""
sorted = []
sorted.extend(grid[0])
n_columns = -1
for row in grid:
for cell in row:
if n_columns < cell.column:
n_columns = cell.column
n_rows = len(grid)
# Add the right column
for row in grid:
for cell in row:
if cell.row == 1:
continue
if cell.column == n_columns:
sorted.append(cell)
# Add the bottom row in reverse (excluding last value)
bottom_row = grid[-1]
for cell in bottom_row[::-1]:
if cell.column == n_columns:
continue
sorted.append(cell)
# Add the left row (excluding first and last value)
left_column = [row[0] for row in grid]
for cell in left_column[::-1]:
if cell.row in [1, n_rows]:
continue
sorted.append(cell)
return sorted
def generate_aggregate_thumbnail(video_file, previous_file=None):
""" Given a video file path, generate a 'summary thumbnail' consisting of a main
(large) thumbnail and thumbnails taken evenly throughout the video surrounding this
main thumbnail """
target_filepath = os.path.join(os.path.dirname(video_file),
os.path.splitext(video_file)[0] + '.JPEG')
if os.path.isfile(target_filepath):
log.info("File already exists: {}".format(os.path.basename(target_filepath)))
return target_filepath
log.info("Working on file {}".format(os.path.basename(video_file)))
grid, small_size = generate_thumbnail_grid()
outer_ring = get_clockwise_outer_grid(grid)
n_thumbnails = len(outer_ring) + 1
try:
thumbnail_filepaths = generate_thumbnails(file_path=video_file,
n=n_thumbnails,
overwrite=False)
except ZeroDurationError as e:
return ""
img_width = 1920
img_height = 1080
blank_image = Image.new('RGB', (img_width, img_height))
main_thumbnail_index = int(len(thumbnail_filepaths)/2)
main_thumbnail = thumbnail_filepaths.pop(main_thumbnail_index)
if len(thumbnail_filepaths) != len(outer_ring):
raise IOError("Not enough thumbnails to put in preview {}/{}".format(len(thumbnail_filepaths),
len(outer_ring)))
# First fill in the outer ring
for i, cell in enumerate(outer_ring):
thumbnail_path = thumbnail_filepaths[i][0]
if os.path.isfile(thumbnail_path):
if previous_file:
os.remove(previous_file)
else:
thumbnail_path = previous_file
log.debug("Working on thumbnail {}: {}".format(i + 1, os.path.basename(thumbnail_path)))
im = Image.open(thumbnail_path)
im.thumbnail(small_size, Image.ANTIALIAS)
blank_image.paste(im, (cell.x_coord, cell.y_coord))
previous_file = thumbnail_path; im = None
os.remove(previous_file)
# Now add the main thumbnail: derive its coordinates from Cell(2,2) and Cell(-2, -2)
main_x_topleft, main_y_topleft = grid[1][1].x_coord, grid[1][1].y_coord
main_x_bottomright = grid[-2][-2].x_coord + small_size.width
main_y_bottomright = grid[-2][-2].y_coord + small_size.height
main_width = main_x_bottomright - main_x_topleft
main_height = main_y_bottomright - main_y_topleft
im = Image.open(main_thumbnail[0])
im.thumbnail((main_width, main_height), Image.ANTIALIAS)
# If the aspect ratio causes the image not to be centered: add whitespace to center
additional_whitespace = int((main_height - im.size[1]) / 2)
main_y_topleft += additional_whitespace
blank_image.paste(im, (main_x_topleft, main_y_topleft))
os.remove(main_thumbnail[0])
blank_image.save(target_filepath, "JPEG")
if not os.path.isfile(target_filepath):
raise IOError("Cannot save aggregated thumbnail")
log.info("Thumbnail generated: {}".format(os.path.basename(target_filepath)))
time.sleep(5)
return(target_filepath)
def main():
log.info("Start via main")
source_folder = "H:/video_folder/"
for root, dirs, files in os.walk(source_folder):
for filename in files:
if os.path.splitext(filename)[1].lower() in ['.mp4']:
filepath = os.path.join(root, filename)
generate_aggregate_thumbnail(video_file=filepath)
else:
log.debug("Ignoring file {}".format(filename))
if __name__ == "__main__":
log = generate_logger(log_path="d:/logger.log",
log_level=logging.INFO)
#log_level=logging.DEBUG)
main()