Time lapse script

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.

Tagged with:
Posted in Python, timelapse

Leave a comment

In Archive
Design a site like this with WordPress.com
Get started