5
\$\begingroup\$

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:

Example thumbnail collage

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()
\$\endgroup\$
1
\$\begingroup\$

Error classes

Is there any good reason why you need the two classes ZeroDurationError, and ffmpegError? They both explicitly inherit from ZeroDivisionError and IOError, so why can't you just use those error classes? I strongly encourage that you check out Stop Writing Classes. It gives a very good reason as to why you probably don't need to define your own custom error:

  • "In all of the code for Python's standard libraries, (200K loc), there are only about 150 defined error classes."

You probably don't need to define your own errors.


Writing docstrings

You're docstring formatting is very inconsistent. For example, you sometimes write docstrings like this:

""" 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 """

Other times, you write them like this:

""" For a given file_path to a video, return its duration in seconds"""

Your docstrings should generally follow a style like this:

"""Brief description of your function/class/file/package

More detailed description of your function/class/file/package.

Keyword arguments: (if it has arguments)
argument_name -- Argument description.
"""

If you do have shorter functions/classes/files/packages, or you simply don't need to write a detailed description, you can always write a one-liner, like this:

"""A description goes here."""

Miscellaneous nitpicks

The following:

if ignore_modules == None:
    ignore_modules = []

Should be re-written to this:

if ignore_modules is None:
    ignore_modules = []

This docstring is too long to be a one-liner. Preferably, it should be expanded out so it's easier to read.

""" 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. """
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.