Read First:

Creating the cropped timelapses!

Anyone can create a full timelapse from images with ffmpeg, it’s just a oneliner like this (framerate is based on 8622 files / 60 seconds):

ffmpeg -framerate 144 -pattern_type glob -i '*.png' -c:v libx264 -pix_fmt yuv420p -filter:v fps=fps=60 FULL.mp4

Instead my niche was to create cropped, zoomed in timelapses of different artworks. The canvas is huge and different communities / subreddits claimed their piece and worked on it throughout the event.

But to easily communicate requests and pick the right areas to crop - I needed some tooling. The Atlas was already established - it was the perfect place to pick coordinates.

Calculating coordinates on the other hand.. At first I started doing it manually by calculating a crop over the area, scalable with 1920x1080.

That got tedious quickly and I created a script which over time grew to this:

Calculate the crop area and aspect ratio

import os
import climage

# Function to sanitize the file/directory-naming
def cleanFilename(sourcestring, removestring=" %:/,.\\[]<>*?"):
    return ''.join([c for c in sourcestring if c not in removestring])

# Function to calculate 16:9 or 9:16 aspect ratio based on input
def image_to_ratio(w, h):
    User_Width= w
    User_Height= h

    while True:
        base = w//16 if w/h < 16/9 else h//9
        base_resolution = ((base * 16), (base * 9))
        # if the suggested resolution is smaller than the user defined, pad with 4 pixels and retry
        if base_resolution[0] < User_Width or base_resolution[1] < User_Height: 
            w+=4
            h+=4
        else:
            if User_Width > User_Height: # portrait or landscape
                return base * 16, base * 9, (1080 / (base * 9)) * 100
            else:
                return base * 9, base * 16, int((1080 / (base * 9)) * 100)

# User input:
name = cleanFilename(input("Name of Community/Artwork: "))
start = list(int(x.strip()) for x in input("Start coordinates: (Top left corner - example: -327,-186) ").split(','))
stop = list(int(x.strip()) for x in input("Stop coordinates: (Bottom right corner - example: 94,-74) ").split(','))

# Adjust for negative coordinates by the atlas: w-1500,h-1000
# The canvas is 3000x2000 and their coordinates is 0 in the center
# While the image processing is 0 in top left corner
start[0] += 1500
start[1] += 1000
stop[0] += 1500
stop[1] += 1000

# calculate resolution based on coordinates
user_resolution = ((stop[0]-start[0]), (stop[1]-start[1]))
print(f"UserRes: {user_resolution}")

# calculate 16:9 aspect ratio and scale percentage
ratio = image_to_ratio(user_resolution[0], user_resolution[1])
print(f"ClosestRes + Multiplier: {ratio}")

# new starting coordinates based on resize of cropped area
new_start = (start[0]-int((ratio[0]-user_resolution[0])/2), start[1]-int((ratio[1]-user_resolution[1])/2))

# create preview of the final image, with my template-canvas.png as base
os.system(f"\nconvert canvas.png -crop {ratio[0]}x{ratio[1]}+{new_start[0]}+{new_start[1]} -filter point -resize {ratio[2]}% test_{name}.png")
# print the line to use in the next step - timelapsing!
print(f"\nconvert canvas.png -crop {ratio[0]}x{ratio[1]}+{new_start[0]}+{new_start[1]} -filter point -resize {ratio[2]}%")

# climage - preview the image in the terminal:
preview = climage.convert(f"test_{name}.png", is_unicode=True, is_truecolor=True, is_256color=False, is_16color=False, is_8color=False, width=120, palette="default") 
print(preview)

With that done, I got a preview out of the final crop and the variables I need for the next script, the actual timelapse.

Time for timelapse!

Now we have the values from the previous script to add to the Crop-variable.

Then I wanted to create a way of choosing start time, as many artworks were in areas not “unlocked” until later in the event. We’ve got the filenames as epoch-time still and people grab the coordinates from the Atlas - so the tooltip timestamp is what we’ll use.

#!/bin/bash
Crop='-crop 48x27+49+1595 -filter point -resize 4000.0%'
ProjectName="DFTBA"
Timestamp="Mon, 24 Jul 2023 02:30:00 GMT"
Length=30 # length of the output clip in seconds

Epoch=$(date -d "${Timestamp} -30 minutes" +"%s")

IFS=$'\n'
FileArray=( "$(ls -1 -- *.png)" ) # adding all png's to array
FileArray+=("${Epoch}.png") # adding fake timestamp-file to array
FileArray=("$(sort <<<"${FileArray[*]}")") # sorting
unset IFS
# remove everything before timestamp file, including the timestamp-file
CleanArray=$(echo "${FileArray[@]}" | sed -ne '/'"$Epoch"'/,${/'"$Epoch"'/!p;}')

# count frames to calculate length
ImageCount=$(printf '%s\n' "${CleanArray[@]}" | wc -l) 
Frames=$(( ImageCount / Length ))

mkdir -p "$ProjectName"

# iterate all the images in the array, crop+scale
Count=0
for f in $CleanArray ; do
  (( Count++ ))
  convert "$f" $Crop "$ProjectName"/"${f/.png/-cropscale.png}"
  # made a counter to show the process
  printf "Processed %s of %s\033[0K\r" "${Count}" "${ImageCount}"
done

cd "$ProjectName" || exit # quit if there's no directory
# stitch together the clip from the cropped images
ffmpeg -framerate $Frames -pattern_type glob -i '*.png' -c:v libx264 -pix_fmt yuv420p -filter:v fps=fps=60 $ProjectName.mp4

cd .. # go back to previous directory before quitting
exit

So that’s that! A timelapse is created.

  • Pick coordinates from the Atlas
  • Run through a python script to calculate the crop ratio and resolution
  • Run through a bash script to mass-crop the images and finally create the timelapse

Cant we do all that in one go?

Well yeah, and over time I kept adding functionality to it so now its rather big considering where we started..

Terminal screenshot of lapsing the Ukraine-flag, coordinates picked from the Atlas:
Screenshot Ukraine

Still got some cleaning to do.. but at the time of writing - this is it:

import sys
import glob
import os
from datetime import datetime
import calendar
import climage

raw_images = "ALL/" # location of raw images
canvas_temp = "canvas.png" # template canvas file
clip_length=30 # length of the output clip in seconds

def clean_dirname(sourcestring, removestring=" %:/,.\\[]<>*?"):
    return ''.join([c for c in sourcestring if c not in removestring])

def image_to_ratio(w, h):
    user_width= w
    user_height= h

    while True:
        base = w//16 if w/h < 16/9 else h//9
        base_resolution = ((base * 16), (base * 9))
        # if the suggested resolution is smaller than the user defined, pad with 4 pixels and retry
        if base_resolution[0] < user_width or base_resolution[1] < user_height: 
            w+=4
            h+=4
        else:
            if user_width > user_height: # portrait or landscape
                return base * 16, base * 9, (1080 / (base * 9)) * 100
            else:
                return base * 9, base * 16, int((1080 / (base * 9)) * 100)

def cd(dir):
    if os.path.exists(dir):
        os.chdir(dir)
    else:
        print(f"{dir} does not exist, exiting.")
        exit()

def uniq_dir(dir):
    counter = 1
    dir_name = dir

    while os.path.exists(dir):
        dir = f"{dir_name}_{counter}"
        counter += 1

    return dir

def progress_counter(current, total):
    percentage = round(100.0 * current/float(total),1)
    sys.stdout.write(f"{current}/{total} images processed, {percentage}% complete \r")
    sys.stdout.flush()


# User input:
project_name = clean_dirname(input("Name of Community/Artwork: "))
start = list(int(x.strip()) for x in input("Start coordinates (format -123,456) :").split(','))
stop = list(int(x.strip()) for x in input("Stop coordinates (format -456,789) :").split(','))
timestamp = input("Atlas Timestamp for starting (leave blank for default): ") or "Thu, 20 Jul 2023 13:01:20 GMT"

# Adjust for negative coordinates by 2023.place-atlas.stefanocoding.me , w-1500,h-1000
start[0] += 1500
start[1] += 1000
stop[0] += 1500
stop[1] += 1000

# calculate resolution based o coordinates
user_resolution = ((stop[0]-start[0]), (stop[1]-start[1]))

# calculate 16:9 aspect ratio and scale percentage
ratio = image_to_ratio(user_resolution[0], user_resolution[1])

# new starting coordinates based on resize of cropped area
# Need to add logic for end of the canvas, in all directions
new_start = [start[0]-int((ratio[0]-user_resolution[0])/2), start[1]-int((ratio[1]-user_resolution[1])/2)]

# logic to stay within the canvas max resolution
for i, s in enumerate(new_start):
    new_start[i] = max(0, new_start[i])
if (new_start[0]+ratio[0]) > 3000:
    new_start[0] = new_start[0] - ((new_start[0]+ratio[0]) - 3000)
if (new_start[1]+ratio[1]) > 2000:
    new_start[1] = new_start[1] - ((new_start[1]+ratio[1]) - 2000)

# create preview-image with imagemagick
os.system(f"\nconvert {canvas_temp} -crop {ratio[0]}x{ratio[1]}+{new_start[0]}+{new_start[1]} -filter point -resize {ratio[2]}% test_{project_name}.png")

print("Preview of the cropped area, will look more pixelated due to the terminal:")
## Climage - show image:
preview = climage.convert(f"test_{project_name}.png", is_unicode=True, is_truecolor=True, is_256color=False, is_16color=False, is_8color=False, width=120, palette="default") 
print(preview)

# Continue? Long time!
user_choice = input("Looks good? Continue with the convertion (can take a while)? y/n: ")
if not (user_choice.lower() == "yes" or user_choice.lower() == "y") :
    exit()

crop_arguments = f"-crop {ratio[0]}x{ratio[1]}+{new_start[0]}+{new_start[1]} -filter point -resize {ratio[2]}%"

epoch = calendar.timegm(datetime.strptime(timestamp, "%a, %d %b %Y %H:%M:%S %Z").utctimetuple())

origin_dir = os.getcwd() # Get current dir for later
raw_images = raw_images.removesuffix("/") # sanitize input
cd(raw_images) # cd to the raw images

file_list = glob.glob("*.png") # list all pngs
file_list.append(f"{epoch}.png") # append fake timestamp-file
file_list.sort() # sort the list
# Grab only images after timestamp
fake_index = file_list.index(f"{epoch}.png") + 1
clean_list = file_list[fake_index:]

# count images to calculate length
image_count = len(clean_list)
frames = image_count / clip_length

# define name and create dir
work_dir = uniq_dir(project_name)
os.mkdir(work_dir)
print(f"Output directory: {work_dir}")

# iterate all the images in the list, crop+scale
que_number = 0
for file in clean_list: # TODO - add a break!
    que_number += 1
    new_filename = file.replace(".png", "-cropscale.png")
    convert_run = f"convert {file} {crop_arguments} {work_dir}/{new_filename}"
    os.system(convert_run)
    progress_counter(que_number, image_count)


cd(work_dir)
# stitch together the clip from the cropped images
ffmpeg_run = f"ffmpeg -loglevel error -stats -framerate {frames} -pattern_type glob -i '*.png' -c:v libx264 -pix_fmt yuv420p -filter:v fps=fps=60 {project_name}.mp4"
os.system(ffmpeg_run)
cd(origin_dir) # return to the starting directory

print(f"All done! Timelapse at {raw_images}/{work_dir}/{project_name}.mp4")
exit()

Watch the complete timelapse created from the previous screenshot.

Now I’m working on a graphical “pick by mouse” feature and also to auto-pick timestamp based on coordinates.
To be continued..? Currently a work in progress at github: mag37/rplace_timelapse