Software development

Time-lapse videos with a GoPro

I haven’t been able to do much coding this year, and my blogging frequency has also decreased dramatically. The reason for this is we bought an old crappy house, tore it down, and then built a new one. Almost all of my free-time is consumed by planning, organizing and doing stuff in and around the house. I’ve spent most of my summer with shovelling and moving earth, sand, dirt and aggregate and building things with concrete. Now that it’s winter, work is moving indoors, and I’ll be busy making furniture for the next few months. Fortunately, there was still an opportunity to write a little code, and it even involved the construction activities. The house we’ve built is of the prefabricated kind, and we’ve actually built two of them at the same time in the same place. And since the construction company erected both houses within two days, I decided to create a neat little time-lapse documentation video. I had never owned or used an action cam before, so I figured that I’d just borrow one from a friend, stick it on a tripod and let it take one frame every two seconds all day long with a large power bank attached to it.

Unfortunately, it’s not that easy. For starters, the GoPro Hero 5 that I was able to source comes with a tripod mount, but you can’t have it mounted and also charge it at the same time. So while it ran on one battery, I had to charge the second one and vice versa. Every sixty minutes or so, I had to stop the recording, swap batteries and restart the recording again. This had to be done as quick as possible as to not disturb the video flow in the end, but I also had to be careful to not change the point of view of the camera at the same time.

The other issue is the way that the GoPro saves time-lapse frames to the flash drive. You’d imagine that the individual frames are all either numbered incrementally or that there is some other obvious name pattern that involves either a time-lapse index, date, time or anything else that lets you identify a specific frame in a specific time-lapse. And while all frames of a single time-lapse are named incrementally, successive time-lapses don’t seem to reflect their order of recording in the numbers given to their frames. The following list shows all frames in their order of creation. GoPro does it weird.

  1. G0015099.JPG to G0019743.JPG (4645 frames)
  2. G0029745.JPG to G0029999.JPG (255 frames)
  3. G0030001.JPG to G0033981.JPG (3981 frames)
  4. G0043982.JPG to G0047615.JPG (3634 frames)
  5. G0058659.JPG to G0058661.JPG (3 frames)
  6. G0068662.JPG to G0068976.JPG (315 frames)
  7. G0078979.JPG to G0079999.JPG (1021 frames)
  8. G0080001.JPG to G0080037.JPG (37 frames)
  9. G0010038.JPG to G0013859.JPG (3822 frames)
  10. G0023860.JPG to G0027612.JPG (3753 frames)
  11. G0037613.JPG to G0037615.JPG (3 frames)
  12. G0047616.JPG to G0049999.JPG (2384 frames)
  13. G0050001.JPG to G0052198.JPG (2198 frames)
  14. G0062199.JPG to G0065211.JPG (3013 frames)
  15. G0075212.JPG to G0076973.JPG (1762 frames)

After two days of recording for eight hours each with time-lapse recordings of roughly sixty minutes and a frame rate of thirty frames per minute, I should have ended up with sixteen individual time-lapses and around 1,800 frames each. Instead, I found fifteen individual time-lapses with anything from 3 to 6,018 frames and in the wrong order. The indexing strategy seems to follow some rules, but I’m not sure if I’ve understood it all. Successive time-lapses can be apart by a gap of 1, but more often the gap has a size of 10,000 to 10,002 with one crazy overflow hiccup of -70,000 and another random 11,043. Again, GoPro does it weird.

Scripting to the rescue

Reordering all of those frames manually is no easy feat, and having a strong dotnet background, I figured that PowerShell is a good choice for batch processing like that. A good first step is to find and split the individual time-lapses into separate directories, where they can be reviewed and reordered manually. A single time-lapse is made up of a number of frames that are named like G[0-9]{7}.JPG with incremental numbers. A missing number serves as a delimiter for a time-lapse, unless one time-lapse is getting large enough that it overwrites the frames of a previous one. To make things easier during processing, I decided to stick to an ordinary zero-based index when copying the individual frames while splitting the time-lapses into clusters of frames. From here on, every piece of code goes into a file called GoProTimeLapse.ps1. Generating the GoPro file name from an index will be used multiple times during processing, so this will be a priority.

function Get-GPTLFileName([int] $index) {
    $paddedIndex = $index.ToString().PadLeft(7, '0')
    return "G$paddedIndex.JPG";
}

Splitting the time-lapses is a matter of checking an entire range of indices in a given directory. As already mentioned, identified time-lapses are saved into individual directories using a zero-based index for both the clusters and the frames within a cluster. A bit of human-readable output of what’s happening won’t hurt, and a friendly reminder to reorder the clusters at the end might be helpful too. My raw data is located in D:\GoPro and I want the clusters to be placed in D:\GoPro_Reordered so a call to . .\GoProTimeLapse.ps1; Split-TLClusters -SourceDir D:\GoPro -TargetDir D:\GoPro_Reordered does it for me.

function Split-TLClusters() {
    param (
        [string] $SourceDir,
        [string] $TargetDir,
        [int] $Start = 0,
        [int] $End = 100000
    )

    $Cluster = 0
    $ClusterI = 0
    $ClusterDir = [System.IO.DirectoryInfo] [System.IO.Path]::Combine($TargetDir, "_$Cluster")
    $ClusterDir.Create()
    $LastFile = ''

    for($I = $Start; $I -le $End; $I++) {
        $SourceFile = [System.IO.FileInfo] [System.IO.Path]::Combine($SourceDir, (Get-GPTLFileName $I))

        if($SourceFile.Exists) {
            if($ClusterI -eq 0) {
                Write-Host "Found first frame of cluster $Cluster : $SourceFile"
            }
            
            $LastFile = $SourceFile
            $TargetFile = [System.IO.FileInfo] [System.IO.Path]::Combine($ClusterDir, "$ClusterI.jpg")
            $ClusterI++

            $SourceFile.CopyTo($TargetFile) | out-null

        } else {
            if($ClusterI -gt 0) {
                Write-Host "Found last frame of cluster $Cluster : $LastFile ($ClusterI frames)"

                $Cluster++
                $ClusterDir = [System.IO.DirectoryInfo] [System.IO.Path]::Combine($TargetDir, "_$Cluster")
                $ClusterDir.Create()
                $ClusterI = 0
            }        
        }
    }

    Write-Host "Please reorder all extracted time lapses in $TargetDir now (use [0-n] for your ordering)."
}

During reviewing and reordering the separated clusters, I had to split a larger 6,018 frames time-lapse into two directly adjacent ones with 3,634 and 2,384 frames each. I’m assuming that the second time-lapse actually overwrote some frames of the first one, because of a gap at this point where the construction process jumps ahead between frames. Getting the indices of the manually created cluster down to zero-based requires another bit of code with a call to . .\GoProTimeLapse.ps1; Floor-TLCluster -Start 3634 -Dir D:\GoPro_Reordered\9.

function Floor-TLCluster() {
    param (
        [int] $Start = 0,
        [string] $Dir
    )

    $ClusterI = $Start
    $SourceFile = [System.IO.FileInfo] [System.IO.Path]::Combine($Dir, "$ClusterI.jpg")

    while($SourceFile.Exists) {
        $TargetFile = [System.IO.FileInfo] [System.IO.Path]::Combine($Dir, "$($ClusterI - $Start).jpg")
        Write-Host "Moving $SourceFile to $TargetFile."
        $SourceFile.MoveTo($TargetFile)
        
        $ClusterI++
        $SourceFile = [System.IO.FileInfo] [System.IO.Path]::Combine($Dir, "$ClusterI.jpg")
    }
}

With all that splitting and sorting being done, the next big step is to rejoin the individual clusters back into a massive chunk of time-lapses. The idea here is to copy the ordered frames in correct order back into a directory of choosing, while keeping a gap of exactly one between the separate time-lapses as a delimiter. And I also want to return to the GoPro naming scheme for consistency reasons. Apparently, there is also some GoPro software that can do things with time-lapses, and it might rely on the original pattern. Again, a PowerShell call does the tricky joinery for us: . .\GoProTimeLapse.ps1; Join-TLClusters -SourceDir D:\GoPro_Reordered -TargetDir D:\GoPro_Reordered

function Join-TLClusters() {
    param (
        [string] $SourceDir,
        [string] $TargetDir
    )

    $Cluster = 0
    $ClusterDir = [System.IO.DirectoryInfo] [System.IO.Path]::Combine($SourceDir, $Cluster)
    $TargetI = 0
    
    while($ClusterDir.Exists) {
        $FirstImage = $TargetI
        Write-Host "Found cluster $Cluster"

        $ClusterI = 0
        $SourceFile = [System.IO.FileInfo] [System.IO.Path]::Combine($ClusterDir, "$ClusterI.jpg")
                
        while($SourceFile.Exists) {
            $TargetFile = [System.IO.FileInfo] [System.IO.Path]::Combine($TargetDir, (Get-GPTLFileName $TargetI))

            $SourceFile.CopyTo($TargetFile) | out-null
            
            $ClusterI++
            $SourceFile = [System.IO.FileInfo] [System.IO.Path]::Combine($ClusterDir, "$ClusterI.jpg")
            $TargetI++
        }

        $LastImage = $TargetI - 1
        $FrameCount = $LastImage - $FirstImage + 1

        Write-Host "Cluster $Cluster with $FrameCount frames processed [$FirstImage .. $LastImage]"

        $Cluster++
        $ClusterDir = [System.IO.DirectoryInfo] [System.IO.Path]::Combine($SourceDir, $Cluster)
        $TargetI++
    }
}

With all the heavy-lifting done, it’s finally time to worry about creating a video from all the frames. There is a plenitude of available offline and online converters, and it doesn’t really matter which one you chose. They all create roughly the same videos when the full range of available frames are used, and they all somewhat fail when trying to create a video from every nth frame for the video. That’s because of the differences in the concepts of time in a video and in a collection of individual time-lapses. A video is a sequence of frames that represent a linear timescale, with the same amount of time passing between consecutive frames. A time-lapse is the same thing, but between the last frame of one time-lapse and the first frame of the next one is a gap in time which doesn’t fit into the linear pattern. A converter is unaware of this fact and will therefore skip too many frames when transitioning from one time-lapse to another.

Here is why that matters. I’ve had the GoPro record the time-lapses with thirty frames per minute, and I’ve taken around twenty seconds to swap the batteries. When a converter is tasked to pick every 10th frame for the video, it will transition from one time-lapse to another somewhere between two chosen frames. Those two frames should be twenty seconds apart, but are actually forty seconds apart due to the additional time required for swapping batteries. A loss of twenty seconds on every transition.

On the other hand, if the converter could detect a transition, this loss of time could be reduced. If the first chosen frame after a transition is actually the first frame of the next time-lapse, then not skipping won’t help, and the result is the same with a loss of twenty seconds. But if the last chosen frame before a transition is actually the last frame of the time-lapse, not skipping any frames will result in both frames being twenty seconds apart and no time is lost. On average, selective skipping will reduce the lost time due to transitions to about ten seconds each and therefore reducing the loss by about 50%. Of course, this is highly dependent on the used frame-rate, the time between consecutive time-lapses and the size of the desired subset. In this case, it doesn’t seem like much to save less than two minutes of time in a video that spans more than sixteen hours, but we’re still talking about a value of 0.15% and that’s a lot more than zero. An error like that for Artemis 1 could have meant a hard landing or a slingshot into a higher orbit. Writing software isn’t guesswork, and it can’t work without precision, so a call to . .\GoProTimeLapse.ps1; Get-NthSubSet -Interval 10 -SourceDir D:\GoPro_Reordered -TargetDir D:\GoPro_10th is all it takes to help the converter do its thing with as little error as possible.

function Get-NthSubSet() {
    param (
        [int] $Start = 0,
        [int] $End = 50000,
        [int] $Interval = 10,
        [string] $SourceDir,
        [string] $TargetDir
    )

    if(Test-Path -PathType Container -Path $TargetDir) {
        Write-Host "Writing files to $TargetDir"

    } else {
        Write-Host "Creating $TargetDir"
        New-Item -ItemType Directory -Path $TargetDir
    }

    [int] $J = 0

    for($I = $Start; $I -le $End; $I++) {
        $FileName = Get-GPTLFileName $I
        $SourceFile = [System.IO.FileInfo] [System.IO.Path]::Combine($SourceDir, $FileName)
        $TargetFile = [System.IO.FileInfo] [System.IO.Path]::Combine($TargetDir, $FileName)

        if($SourceFile.Exists) {
            if($J % $Interval -eq 0) {
                Write-Host "Taking $SourceFile."
                Copy-Item -Path $SourceFile -Destination $TargetDir
            }

            $J++

        } else {
            $J = 0
        }
    }
}

This article has become a lot longer than I thought, but it’s likely the last software related article for a while now. Future articles will be more about my construction work with the house, building furniture and other house-related topics. But I have great hopes that I can return to software development sometime around July or August with a new major release of my test platform. And, just maybe, there might be a chance for one or two new releases of beefed-up legacy code from my monster legacy repository.

Leave a Reply

Your email address will not be published. Required fields are marked *