I’ve done time lapses before and now here’s the script I made to produce them.
It uses ffmpeg to produce the videos from the thousands of pictures that my camera takes for a time lapse. Now it is rather easy to make a time lapse if all the pictures are nicely named in a single folder, but that’s not how the camera works. The camera saves only a thousand images to a folder before making a new folder, and the naming method loops around after its counter hits 9999(I can reset this counter before I start the camera, but where’s the laziness in that?).
Additionally, there was some stuff I wanted to know about the time lapse (how fast it is, when it ran, how long it ran, etc.) that I wouldn’t get from just doing ffmpg -r 30 -i "C:\timelapse\frame%08d.JPG" "output.mp4", so that’s what this Python script does. It gathers such info from the images and builds the video.
The script used to copy all the images from the camera card and put them into one folder and rename them, but now uses ffmpeg’s -concat feature to cut out the copying entirely. I found this slightly slower to build, but the copying step was longer to run making the whole process much faster.
The script has reached the point where I can just plug the camera card into my computer and run it without having to edit anything within it. Here’s a time lapse along with the output of the script:
Number of Images: 6,469 Combined size: 8.95 GB
First Image: Apr 28, 2016 - 09:34:21 AM
(3 hours, 57 minutes, 43 seconds AFTER Sunrise)
Last Image: Apr 28, 2016 - 06:33:19 PM
(1 hour, 21 minutes, 40 seconds BEFORE Sunset)
Average time between images: 5 seconds
Sunrise and Sunset for the first and last images' date:
Apr 28, 2016 - 05:36:38 AM
Apr 28, 2016 - 07:54:59 PM
Time lapse covers 8 hours, 58 minutes, 57.500 seconds.
Time lapse is 3 minutes, 34 seconds long.
Time compression is 151.100 X
That is, 1 second = 2 minutes, 31.100 seconds
File sizes
Original images: 8.95 GB
Video size: 151.13 MB
Total: 9.09 GB
Image to video compression ratio: 98.350%
total processing time: 10 minutes, 58.775 seconds
Additionally, music is Dream Culture by Kevin MacLeod.
And finally, the script. It uses a number of functions I’ve previously done (see here and here).
from PIL import Image, ImageDraw, ImageFont
from datetime import datetime, timedelta
import subprocess as sp
import os, time, sys
import shutil
try:
import ephem, pytz
have_ephem = True
except:
have_ephem = False
ffmpeg = 'path/to/ffmpeg'
# where the images are, and their type
img_fold = "N:/DCIM"
img_ext = '.JPG'#.lower()
search_sub = True
# set search_sub to True to get all images in every subfolder of
# img_fold - useful for cameras that store images in multiple folders
# where to put related files and output video
wrk_fold = "C:\\time_lapse"
vid_ext = '.mp4'
vid_name = None
# if vid_name is None, a name will automatically be generated for it
# following this format: dayname_monthname_day_year
# how long video should be
vid_length = None
# should be a string formatted as hh:mm:ss, I. E. "00:03:09"
# if set to None video length will be set by the frame rate
# best to set this when adding audio (see below)
# add audio
audio_path = None
# if set to None, no audio will be added, otherwise should
# be a path to the audio file to be added.
# other options
timestampOption = False # add the time to each frame
Date_Format = 2 # 1: date only, 2: time only, anything else: date + time
resizePercent = 0.5 # % reduction - the smaller it is, the smaller the video
# set to 1 or 0 for no resizing
fps = 30 # frame rate
Me = 'Orthallelous' # who you are! (for metadata stamped to video)
if have_ephem: # for sunrise, sunset related stats
ObsLoc = ephem.Observer()
ObsLoc.name = 'UFO Mooring Site'
ObsLoc.lat = '44.590539' # degrees North
ObsLoc.long = '-104.715522' # degrees East
ObsLoc.elevation = 1558 # meters from sea level (not necessary)
TZone = 'US/Mountain' # timezone name as a string
fmt = "%b %d, %Y - %I:%M:%S %p" # time format (used only in the log file)
__doc__ = """
script using ffmpeg with -f concat to join images together into a
time lapse video while collecting information related to the video
such as how fast it is compared to normal time speed, sunrise and sunset
times (if ephem and pytz are installed) and file sizes.
"""
sub_fold = os.path.join(wrk_fold, 'modified')
# video and image related functions -------------------------------------------
# http://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif.html
def imgDate(fn):
"returns the image date from image (if available)"
std_fmt = '%Y:%m:%d %H:%M:%S.%f'
# for subsecond prec, see doi.org/10.3189/2013JoG12J126 , sect. 2.2, 2.3
tags = [(36867, 37521), # (DateTimeOriginal, SubsecTimeOriginal)
(36868, 37522), # (DateTimeDigitized, SubsecTimeOriginal)
(306, 37520), ] # (DateTime, SubsecTime)
exif = Image.open(fn)._getexif()
for t in tags:
dat_stmp = exif.get(t[0])
sub_stmp = exif.get(t[1], 0)
# PIL.PILLOW_VERSION >= 3.0 returns a tuple
dat_stmp = dat_stmp[0] if type(dat_stmp) == tuple else dat_stmp
sub_stmp = sub_stmp[0] if type(sub_stmp) == tuple else sub_stmp
if dat_stmp != None: break
if dat_stmp == None: return None
full = '{}.{}'.format(dat_stmp, sub_stmp)
#T = datetime.strptime(full, std_fmt)
T = time.mktime(time.strptime(full, std_fmt)) + float('0.' + sub_stmp)
return T
def oldest(fn):
"returns the oldest date on the image file"
di = imgDate(fn)
if di: return di # if there's an image date, use that instead
cd = os.path.getctime(fn) # creation time (or last changed time on UNIX)
md = os.path.getmtime(fn) # modified time
ad = os.path.getatime(fn) # accessed time
dates = [cd, md, ad]
return sorted(dates)[0]
def getFiles(fold, ext):
"gets all files of extension from folder"
files = []
for fn in os.listdir(fold):
if os.path.splitext(fn)[-1] == ext:
files.append(os.path.join(fold, fn))
return files
def getRecurisve(root, ext):
"returns all files of extension from all folders of root"
files = []; splitext = os.path.splitext
for fold, dirs, f in os.walk(root):
for fn in f:
if splitext(fn)[-1] == ext:
files.append(os.path.join(fold, fn))
return files
def timecode(code):
"'02:23:13.333' -> 8593.333\n6357 -> '01:45:57.000'"
D, H, M = 86400, 3600, 60
if type(code) in (str, unicode):
h, m, s = code.split(':')
result = int(h) * H + int(m) * M + float(s)
elif type(code) in (int, float, long):
h = int(code // H); code -= h * H
m = int(code // M); code -= m * M
result = ':'.join(('%02d'%h, '%02d'%m, '%06.3f'%code))
else: raise ValueError
return result
def timeStamp(lst, dates=None):
"Stamps the date of an image on the image for images' path in lst"
# to keep correct metadata,
# see http://stackoverflow.com/a/22063878
date_fmt = "%b %d, %Y"
time_fmt = "%I:%M:%S %p"
dt_fmt = date_fmt + ' - ' + time_fmt
if Date_Format == 1: t_fmt = date_fmt
elif Date_Format == 2: t_fmt = time_fmt
else: t_fmt = dt_fmt
color = 'yellow'
if os.name == 'nt': # windows
fnt = "MOD20.TTF"
Font = ImageFont.truetype(fnt, 50)
else: # linux
fnt = "/usr/share/fonts/truetype/freefont/FreeSerif.ttf"
Font = ImageFont.truetype(fnt, 50)
# for fonts, look at control panel > fonts.
# use the file name, not the displayed name
# ref: http://stackoverflow.com/a/15857229
modified = []
for i, fn in enumerate(lst):
img = Image.open(fn)
try: dt = dates[fn]
except: dt = oldest(fn)
dto = time.strftime(t_fmt, time.localtime(dt))
strTime = ' ' + dto
ext = os.path.splitext(fn)[-1]
name = os.path.join(sub_fold, 'TL{:09d}{}'.format(i, ext))
txt = ImageDraw.Draw(img)
#pos = (1, 1) # upper left
pos = (1, img.size[1] - (80)) # lower left
txt.text(pos, strTime, fill=color, font=Font)
dat = img.info['exif']
img.save(name ,exif = dat) # save in sub_fold
modified.append(name)
return modified
def makeAppendFile(VidList):
"creates a txt file of videos to be concatenated\nreturns txt location"
name = os.path.join(wrk_fold, 'ConcatVidList.txt')
txt = open(name, 'w')
for vid in VidList:
vid = vid.replace('\\', '/')
txt.write("file '{}'\n".format(vid))
txt.close()
return name
def metaData():
global Me
year = datetime.now().year
YTags = ['year="{}"', 'Year="{}"', 'ICRD="{}"']
NTags = ['author="{}"', 'artist="{}"', 'IART="{}"',
'description="Timelapse by {}"', 'ICMT="Timelapse by {}"',
'synopsis="Timelapse by {}"', 'ICMT="A Timelapse by {}"',
'comment="By {}"', 'IPRD="Product of {}"',
'ISRC="{}"', 'ISFT="{}"']
BTags = ['copyright="Copyright {} {}"', 'Copyright="Copyright {} {}"',
'COPYRIGHT="Copyright {} {}"', 'ICOP="Copyright {} {}"',
'\251cpy="Copyright {} {}"']
meta = ['IKEY="Timelapse"', 'ISBJ="Timelaspe"', 'genre="Timelapse"']
for i in YTags: meta.append(i.format(year))
for i in NTags: meta.append(i.format(Me))
for i in BTags: meta.append(i.format(year, Me))
metadata = [' '.join(['-metadata', M]) for M in meta]
return metadata
def createVideo(img_lst, fRate=15):
"builds video from a sorted list of image paths with ffmpeg"
global resizePercent
global timestampOption
try: metadata = metaData()
except: metadata = []
# resize video
if resizePercent > 0 and resizePercent != 1:
rs = 'scale=iw*{}:-1'.format(resizePercent)
else: rs = ''
# set video rate to get requested length
if vid_length != None:
cur = len(img_lst) / float(fps)
secs = float(timecode(vid_length))
new = secs / cur
vl = 'setpts={}*PTS'.format(new)
else: vl = ''
# combine the options, ffmpeg is faster that way
opts = [rs, vl]
options = ['-vf', ','.join(filter(None, opts))]
# add audio
if audio_path != None:
aud = ['-i "{}"'.format(audio_path)] # -acodec copy -shortest
else: aud = []
txt_file = makeAppendFile(img_lst)
# assemble command line
video = ([ffmpeg, '-r {}'.format(fRate), # ffmpeg location and frame rate
'-f concat -safe 0 -i "{}"'.format(txt_file), # where images are
] + aud + options + metadata + [ # additional options
'-vcodec libx264', # HD mp4 codec
'-preset slow -crf 18', # settings for mp4 codec
##https://trac.ffmpeg.org/wiki/Encode/H.264
'"{}"'.format(vid_name)]) # output location
#print(' '.join(video))
shl = False if os.name == 'nt' else True
sp.check_call(' '.join(video), shell=shl)
try: os.remove(txt_file)
except: pass
return ' '.join(video)
# functions used for stats ----------------------------------------------------
_numfctr = 1024. if os.name == 'nt' else 1000.
def numBytes(bite, fctr=_numfctr):
"2746231 -> '2.62 MB'"
if not bite: return '0 B'
s = bite // abs(bite)
fctr = float(fctr); bite = abs(bite)
fx = ['B', 'kB', 'MB', 'GB',
'TB', 'PB', 'EB', 'ZB', 'YB']; p = -1
for i in fx:
if bite = 0 else '- ') + amt
def DeltaT(sec, st=0):
"75 -> '1 minute, 15 seconds'"
if sec==0:return 'no elasped time'
S,Q,c,t=[],abs(sec),0,''
N=['second','minute','hour','day','week']
for j in[60,60,24,7]:Q,r=divmod(Q,j);S.append(r)
S.append(Q)
if st8 and t[0]in'0:':t=t[1:]
return t
for T in S:
if T==0:c+=1;continue
v=str(int(T))
if c==0 and int(T)!=T:v='{:.3f}'.format(T)
n=N[c];c+=1
if T!=1 and not st:n=''.join([n,'s'])
if st:t=''.join([v,n[0],' ',t])
else:t=''.join([v,' ',n,', ',t])
return t.rstrip(', ')
def get_tree_size(path):
"Return total size of all files in directory tree at path."
# ref: http://stackoverflow.com/a/22536667
return sum(os.path.getsize(os.path.join(root, filename))
for root, dirs, files in os.walk(path)
for filename in files)
def DawnDusk(year, month, day, tze=TZone):
"returns the sunrise and sunset times for given date"
if not tze: # none specified, use utc
zone = pytz.utc
elif type(tze) == str: # just the name, convert to pytz
zone = pytz.timezone(tze)
else: zone = tze # its a pytz.timezone
loc_date = datetime(year, month, day, 12, 0, 0, 0, zone)
# ephem does all times in utc, so convert to utc
utc_dt = loc_date.astimezone(pytz.utc)
# ref: http://stackoverflow.com/a/8778548
ObsLoc.date = utc_dt
sun = ephem.Sun()
sun.compute(ObsLoc)
# sunrise, sunset
Rise = ObsLoc.previous_rising(sun)
Set = ObsLoc.next_setting(sun)
# convert back to local datetime
rise_local = Rise.datetime().replace(tzinfo=pytz.utc).astimezone(zone)
set_local = Set.datetime().replace(tzinfo=pytz.utc).astimezone(zone)
return rise_local, set_local
def DeltaDawnDusk(dt):
"""returns how far away the sunrise and sunset are from a datetime object
must be timezone aware!"""
Srise, Sset = DawnDusk(dt.year, dt.month, dt.day)
ToSrise = dt - Srise
ToSset = dt - Sset
toR = ToSrise.total_seconds()
toS = ToSset.total_seconds()
RiseStr = '{}'.format('AFTER Sunrise' if toR >= 0 else 'BEFORE Sunrise')
SetStr = '{}'.format('AFTER Sunset' if toS >= 0 else 'BEFORE Sunset')
RiseStr = '{} {}'.format(DeltaT(round(abs(toR))), RiseStr)
SetStr = '{} {}'.format(DeltaT(round(abs(toS))), SetStr)
# return the closest one
if abs(toR) 0 and resizePercent != 1: rs = True
else: rs = False
if rs: roi = ' ({})'.format('by {:.2%}'.format(1-resizePercent))
tmp5 = 'Images will be resized{}'.format(roi)
tmp6 = 'Images will not be resized'
line += ' {}\n'.format(tmp5 if rs else tmp6)
line += ' Video frame rate is set for {} fps\n'.format(fps)
if audio_path: line += ' Added audio is\n {}\n'.format(audio_path)
print(line); log.write(line)
# file preparation point -----------------------------------
if os.path.exists(sub_fold): shutil.rmtree(sub_fold) # bleeeh
if not os.path.exists(sub_fold): os.makedirs(sub_fold)
# acquire the images and get their times
tfd_s = time.time()
line = 'Getting images and dates... '
sys.stdout.write(line)
if search_sub: fyles = getRecurisve(img_fold, img_ext)
else: fyles = getFiles(img_fold, img_ext)
dates = {i: oldest(i) for i in fyles}
tfd_e = time.time(); tfd_l = tfd_e - tfd_s
sys.stdout.write(DeltaT(tfd_l) + '\n')
time_parts.append(line + DeltaT(tfd_l))
# print info
isar = 'is' if len(fyles) == 1 else 'are'
is_1 = '' if len(fyles) == 1 else 's'
idit = '\n (why are you making a time lapse of a single image?)' if len(fyles) == 1 else ''
info = 'There {} {:,} image{}{}'.format(isar, len(fyles), is_1, idit)
print(info); log.write(info + '\n')
if not vid_length:
info = 'Video will be {} long\n'.format(DeltaT(len(fyles)/ float(fps)))
else:
info = 'Video will be {} long\n'.format(DeltaT(timecode(vid_length)))
print(info); log.write(info + '\n')
# sorting section
tsort_s = time.time()
line = 'Sorting images... '
sys.stdout.write(line)
fyles = sorted(fyles, key=dates.get)
tsort_e = time.time(); tsort_l = tsort_e - tsort_s
sys.stdout.write(DeltaT(tsort_l) + '\n')
time_parts.append(line + DeltaT(tsort_l))
# get oldest/newest images
ttzz = pytz.timezone(TZone) if have_ephem else None
Old = datetime.fromtimestamp(dates.get(fyles[0]), ttzz)
New = datetime.fromtimestamp(dates.get(fyles[-1]), ttzz)
# date stamping
if timestampOption:
tstmp_s = time.time()
line = 'Stamping date on the images... '
sys.stdout.write(line)
modfyles = timeStamp(fyles, dates)
tstmp_e = time.time(); tstmp_l = tstmp_e - tstmp_s
sys.stdout.write(DeltaT(tstmp_l) + '\n')
time_parts.append(line + DeltaT(tstmp_l))
# video creation point -------------------------------------
tvid_s = time.time()
line = 'Creating video... '
sys.stdout.write(line)
if modfyles: cmdd = createVideo(modfyles, fps)
else: cmdd = createVideo(fyles, fps)
if cmdd != None: log.write('\nffmpeg command: {}\n\n'.format(cmdd))
tvid_e = time.time(); tvid_l = tvid_e - tvid_s
sys.stdout.write(DeltaT(tvid_l) + '\n')
time_parts.append(line + DeltaT(tvid_l))
# stat collection point ------------------------------------
tstat_s = time.time()
line = 'Collecting stats... '
sys.stdout.write(line)
dist = [] # avg time between images
for i, fn in enumerate(fyles[1:]):
t2 = dates[fn]
t1 = dates[fyles[i]]
dist.append(t2 - t1)
avgdist = round(sum(dist) / float(len(dist)), 3)
if not vid_length:
vid_time = len(fyles) / float(fps)
else:
vid_time = timecode(vid_length)
compress_fctr = (New - Old).total_seconds() / vid_time # time compression
compress_fctr = round(compress_fctr, 1)
ImgSizes = modImgSizes = 0 # file sizes
for fn in fyles: # original images' file sizes
ImgSizes += os.path.getsize(fn)
for fn in modfyles: # modified images' file sizes
modImgSizes += os.path.getsize(fn)
Vidsize = os.path.getsize(vid_name) # video size
TotalSize = ImgSizes + modImgSizes + Vidsize
if have_ephem: # find out how far away the images were from sun rise/set
old_ddd = '\n ({})'.format(DeltaDawnDusk(Old))
new_ddd = '\n ({})'.format(DeltaDawnDusk(New))
else: old_ddd = new_ddd = ''
tstat_e = time.time(); tstat_l = tstat_e - tstat_s
sys.stdout.write(DeltaT(tstat_l) + '\n')
time_parts.append(line + DeltaT(tstat_l))
# clean up point -------------------------------------------
tcp_s = time.time()
line = "Cleaning up (original images won't be deleted)... "
sys.stdout.write(line)
shutil.rmtree(sub_fold)
tcp_e = time.time(); tcp_l = tcp_e - tcp_s
sys.stdout.write(DeltaT(tcp_l) + '\n')
time_parts.append(line + DeltaT(tcp_l))
# show stats ------------------------------------------------
log.write(('-'*50) + '\n')
log.write('\n'.join(time_parts))
line = '\n' + ('-'*50)
line += '\nFinish {:{}}\n\n'.format(datetime.now(), fmt)
line += 'Stats:\n'
line += 'Number of Images: {:,} \tCombined size: {}\n'.format(len(fyles), numBytes(ImgSizes))
line += ' First Image: {:{}}{}\n'.format(Old, fmt, old_ddd)
line += ' Last Image: {:{}}{}\n'.format(New, fmt, new_ddd)
line += 'Average time between images: {}\n'.format(DeltaT(avgdist))
log.write(line + '\n'); print(line)
if have_ephem:
srf, ssf = DawnDusk(Old.year, Old.month, Old.day)
srl, ssl = DawnDusk(New.year, New.month, New.day)
if srl == srf: # same day
lne = "Sunrise and Sunset for the first and last images' date:\n"
lne += ' {}\n'.format(srf.strftime(fmt))
lne += ' {}\n\n'.format(ssf.strftime(fmt))
print(lne); log.write(lne)
else:
lne = 'Sunrise and Sunset for the date of the first image:\n'
lne += ' {}\n'.format(srf.strftime(fmt))
lne += ' {}\n'.format(ssf.strftime(fmt))
lne += '\nSunrise and Sunset for the date of the last image:\n'
lne += ' {}\n'.format(srl.strftime(fmt))
lne += ' {}\n\n'.format(ssl.strftime(fmt))
print(lne); log.write(lne)
line = 'Time lapse covers {}.\n'.format(DeltaT((New - Old).total_seconds()))
line += 'Time lapse is {} long.\n'.format(DeltaT(vid_time))
fctr = int(compress_fctr)
if int(compress_fctr) != compress_fctr: fctr = '%0.03f'%float(compress_fctr)
line += 'Time compression is {} X\n'.format(fctr)
line += 'That is, 1 second = {}\n'.format(DeltaT(compress_fctr))
log.write(line + '\n'); print(line)
line = 'File sizes\nOriginal images: {:>9}\n'.format(numBytes(ImgSizes))
if modImgSizes:
line += 'Modified images: {:>9}\n'.format(numBytes(modImgSizes))
line += 'Video size: {:>9}\n'.format(numBytes(Vidsize))
line += 'Total: {}\n\n'.format(numBytes(TotalSize))
line += 'Image to video compression ratio: {:07.3%}\n'.format(1 - float(Vidsize)/ ImgSizes)
if modImgSizes:
line += 'Modified image to video compression ratio: {:07.3%}\n'.format(1 - float(Vidsize) / modImgSizes)
line += 'space cleaned up: {} (Modified images)\n'.format(numBytes(modImgSizes))
line += '\ntotal processing time: {}\n'.format(DeltaT(time.time() - start_time))
print(line); log.write(line)
log.close()
create_timelapse()
Note: if you have ephem (used for finding the sunrise and sunset times), the functions in the script here will crash if you just happened to have coordinates above the Arctic Circle or below the Antarctic Circle since I never bothered to make said functions handle those places correctly. I’ll eventually get around to that.
Leave a comment