Modify Date Taken values on Photos with PowerShell, the Update-ExifDateTaken script cmdlet

(Part 3 of 3)    Download Script Here

The last two posts (Part 1, Part 2) have described the rationale behind creating these Exif cmdlets and looked at how to read Exif information from image files.  This final part looks at the Update-ExifDateTaken script cmdlet which will modify Exif meta-data contained within photo (.jpg) files.

See the examples in Part 1 for some ways you can use these script cmdlets.

Scott-Hanselman-Style DISCLAIMER: You Do Backup Your Photos Don’t You?  I am not a .Net programmer.  I have not be paid to program anything but PowerShell since around 1987 (seriously, I’m old) and .Net didn’t exist then.  The last non-PowerShell program I was actually paid to write was written in IBM System/370 Assembler.  No one taught me about .Net programming, I only know about it through PowerShell and MSDN and Bruce Payette.  I’ve used this script without any problems on literally thousands of photos.  I’m not saying don’t use it – just use it on a COPY of your precious images.

So, first up, because the script modifies files it must support the “-WhatIf” parameter.  There are two new parameters Update-ExifDateTaken version of the script: “-Offset”, which allows us to change the Exif Date Taken value by a specified amount; and “-PassThru”, which will pass the PathInfo objects along the pipeline.

(Note that Get-ExifDateTaken always passes on the PathInfo objects with the additional  Exif DateTaken [datetime] attribute; there would be no point in running the script if the output was not passed on because it otherwise has no side-effects.  In this Update version the output is optional because just updating the Exif data might be all that is needed.   If you want the output in the pipeline, specify –PassThru and you’ll get PathInfo objects decorated with additional ExifDateTaken and ExifOriginalDateTaken attributes).

The Process{} block structure is similar to the Get-ExifDateTaken script, but now there’s an extra step; once the script has the DateTaken value it updates it by adding the specified offset and replaces the value in the image file.

Pertinent points here are that to avoid file locking problems the script creates a MemoryStream object and saves the modified image data to the MemoryStream; it can then close the original input file and overwrite it (‘in-place’) by re-opening the file with a filemode of ‘Create’.   Note that this part of the code is protected by the ‘If ($PSCmdlet.ShouldProcess(…))’ test that supports the –WhatIf and –Confirm common parameters.

If the –PassThru switch parameter is the script outputs the current file’s PathInfo object along with two added attributes: the ExifDateTaken [datetime] object which reflects the modified DateTaken value; and the ExifOriginalDateTaken [datetime] object, in case that might be needed later in the pipeline.

That wraps it up.  The full download is available on SkyDrive here.  Finally, here’s the full script listing, including both the Get- and Update- variants of the script cmdlet:

ExifDateTime.ps1
<#Chris Warwick, @cjwarwickps, August 2013
chrisjwarwick.wordpress.comRevision: Now support PowerShell version 2.0 and above.This version published on SkyDrive here:
https://skydrive.live.com/redir?resid=7CB58BE453F7E567!1289&authkey=!ANY88H3ABytkigkThe script file contains two functions:

Get-ExifDateTaken -Path [filepaths]

Takes a file (fileinfo or string) or an array of these
Gets the ExifDT value (EXIF Property 36867)

Update-ExifDateTaken -Path [filepaths] -Offset [TimeSpan]

Takes a file (fileinfo or string) or an array of these
Modifies the ExifDT value (EXIF Property 36867) as specified

# Further samples:

# Just Update

gci *.jpg|Update-ExifDateTaken -Offset ‘-0:07:10’ -PassThru|ft Path, ExifDateTaken

# Update & Rename

gci *.jpg|
Update-ExifDateTaken -Offset ‘-0:07:10’ -PassThru|
Rename-Item -NewName {“LeJog 2011 {0:MM-dd HH.mm.ss dddd} ({1}).jpg” -f $_.ExifDateTaken, (Split-Path (Split-Path $_) -Leaf)}

# Just Rename

gci *.jpg|
Get-ExifDateTaken |
Rename-Item -NewName {“LeJog 2011 {0:MM-dd HH.mm.ss dddd} ({1}).jpg” -f $_.ExifDateTaken, (Split-Path (Split-Path $_) -Leaf)}

#>

#Requires -Version 2.0

Function Get-ExifDateTaken {
<#
.Synopsis
Gets the DateTaken EXIF property in an image file.
.DESCRIPTION
This script cmdlet reads the EXIF DateTaken property in an image and passes is down the pipeline
attached to the PathInfo item of the image file.
.PARAMETER Path
The image file or files to process.
.EXAMPLE
Get-ExifDateTaken img3.jpg
(Reads the img3.jpg file and returns the im3.jpg PathInfo item with the EXIF DateTaken attached)
.EXAMPLE
Get-ExifDateTaken *3.jpg |ft path, exifdatetaken
(Output the EXIF DateTaken values for all matching files in the current folder)
.EXAMPLE
gci *.jpeg,*.jpg|Get-ExifDateTaken
(Read multiple files from the pipeline)
.EXAMPLE
gci *.jpg|Get-ExifDateTaken|Rename-Item -NewName {“LeJog 2011 {0:MM-dd HH.mm.ss}.jpg” -f $_.ExifDateTaken}
(Gets the EXIF DateTaken on multiple files and renames the files based on the time)
.OUTPUTS
The scripcmdlet outputs PathInfo objects with an additional ExifDateTaken
property that can be used for later processing.
.FUNCTIONALITY
Gets the EXIF DateTaken image property on a specified image file.
#>

[CmdletBinding()]

Param (
[Parameter(Mandatory=$True,ValueFromPipeline=$True,ValueFromPipelineByPropertyName=$True)]
[Alias(‘FullName’, ‘FileName’)]
$Path
)

Begin
{
Set-StrictMode -Version Latest
If ($PSVersionTable.PSVersion.Major -lt 3) {
Add-Type -AssemblyName “System.Drawing”
}
}

Process
{
# Cater for arrays of filenames and wild-cards by using Resolve-Path
Write-Verbose “Processing input item ‘$Path‘”

$PathItems=Resolve-Path $Path -ErrorAction SilentlyContinue -ErrorVariable ResolveError
If ($ResolveError) {
Write-Warning “Bad path ‘$Path‘ ($($ResolveError[0].CategoryInfo.Category))”
}

Foreach ($PathItem in $PathItems) {
# Read the current file and extract the Exif DateTaken property

$ImageFile=(Get-ChildItem $PathItem.Path).FullName

Try {
$FileStream=New-Object System.IO.FileStream($ImageFile,
[System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read,
[System.IO.FileShare]::Read,
1024,     # Buffer size
[System.IO.FileOptions]::SequentialScan
)
$Img=[System.Drawing.Imaging.Metafile]::FromStream($FileStream)
$ExifDT=$Img.GetPropertyItem(‘36867’)
}
Catch{
Write-Warning “Check $ImageFile is a valid image file ($_)”
If ($Img) {$Img.Dispose()}
If ($FileStream) {$FileStream.Close()}
Break
}
# Convert the raw Exif data

Try {
$ExifDtString=[System.Text.Encoding]::ASCII.GetString($ExifDT.Value)

# Convert the result to a [DateTime]
# Note: This looks like a string, but it has a trailing zero (0x00) character that
# confuses ParseExact unless we include the zero in the ParseExact pattern….

$OldTime=[datetime]::ParseExact($ExifDtString,“yyyy:MM:dd HH:mm:ss`0”,$Null)
}
Catch {
Write-Warning “Problem reading Exif DateTaken string in $ImageFile ($_)”
Break
}
Finally {
If ($Img) {$Img.Dispose()}
If ($FileStream) {$FileStream.Close()}
}

Write-Verbose “Extracted EXIF infomation from $ImageFile
Write-Verbose “Original Time is $($OldTime.ToString(‘F’))

# Decorate the path object with the EXIF dates and pass it on…

$PathItem | Add-Member -MemberType NoteProperty -Name ExifDateTaken -Value $OldTime -PassThru

} # End Foreach Path

} # End Process Block

End
{
# There is no end processing…
}

} # End Function

# ——————————————————————————————————

Function Update-ExifDateTaken {
<#
.Synopsis
Changes the DateTaken EXIF property in an image file.
.DESCRIPTION
This script cmdlet updates the EXIF DateTaken property in an image by adding an offset to the
existing DateTime value.  The offset (which must be able to be interpreted as a [TimeSpan] type)
can be positive or negative – moving the DateTaken value to a later or earlier time, respectively.
This can be useful (for example) to correct times where the camera clock was wrong for some reason –
perhaps because of timezones; or to synchronise photo times from different cameras.
.PARAMETER Path
The image file or files to process.
.PARAMETER Offset
The time offset by which the EXIF DateTaken value should be adjusted.
Offset can be positive or negative and must be convertible to a [TimeSpan] type.
.PARAMETER PassThru
Switch parameter, if specified the paths of the image files processed are written to the pipeline.
The PathInfo objects are additionally decorated with the Old and New EXIF DateTaken values.
.EXAMPLE
Update-ExifDateTaken img3.jpg -Offset 0:10:0  -WhatIf
(Update the img3.jpg file, adding 10 minutes to the DateTaken property)
.EXAMPLE
Update-ExifDateTaken *3.jpg -Offset -0:01:30 -Passthru|ft path, exifdatetaken
(Subtract 1 Minute 30 Seconds from the DateTaken value on all matching files in the current folder)
.EXAMPLE
gci *.jpeg,*.jpg|Update-ExifDateTaken -Offset 0:05:00
(Update multiple files from the pipeline)
.EXAMPLE
gci *.jpg|Update-ExifDateTaken -Offset 0:5:0 -PassThru|Rename-Item -NewName {“LeJog 2011 {0:MM-dd HH.mm.ss}.jpg” -f $_.ExifDateTaken}
(Updates the EXIF DateTaken on multiple files and renames the files based on the new time)
.OUTPUTS
If -PassThru is specified, the scripcmdlet outputs PathInfo objects with additional ExifDateTaken
and ExifOriginalDateTaken properties that can be used for later processing.
.NOTES
This scriptcmdlet will overwrite files without warning – take backups first…
.FUNCTIONALITY
Modifies the EXIF DateTaken image property on a specified image file.
#>

[CmdletBinding(SupportsShouldProcess=$True)]

Param (
[Parameter(Mandatory=$True,ValueFromPipeline=$True,ValueFromPipelineByPropertyName=$True)]
[Alias(‘FullName’, ‘FileName’)]
$Path,

[Parameter(Mandatory=$True)]
[TimeSpan]$Offset,

[Switch]$PassThru
)

Begin
{
Set-StrictMode -Version Latest
If ($PSVersionTable.PSVersion.Major -lt 3) {
Add-Type -AssemblyName “System.Drawing”
}

}

Process
{
# Cater for arrays of filenames and wild-cards by using Resolve-Path
Write-Verbose “Processing input item ‘$Path‘”

$PathItems=Resolve-Path $Path -ErrorAction SilentlyContinue -ErrorVariable ResolveError
If ($ResolveError) {
Write-Warning “Bad path ‘$Path‘ ($($ResolveError[0].CategoryInfo.Category))”
}

Foreach ($PathItem in $PathItems) {
# Read the current file and extract the Exif DateTaken property

$ImageFile=(Get-ChildItem $PathItem.Path).FullName

Try {
$FileStream=New-Object System.IO.FileStream($ImageFile,
[System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read,
[System.IO.FileShare]::Read,
1024,     # Buffer size
[System.IO.FileOptions]::SequentialScan
)
$Img=[System.Drawing.Imaging.Metafile]::FromStream($FileStream)
$ExifDT=$Img.GetPropertyItem(‘36867’)
}
Catch{
Write-Warning “Check $ImageFile is a valid image file ($_)”
If ($Img) {$Img.Dispose()}
If ($FileStream) {$FileStream.Close()}
Break
}
#region Convert the raw Exif data and modify the time

Try {
$ExifDtString=[System.Text.Encoding]::ASCII.GetString($ExifDT.Value)

# Convert the result to a [DateTime]
# Note: This looks like a string, but it has a trailing zero (0x00) character that
# confuses ParseExact unless we include the zero in the ParseExact pattern….

$OldTime=[datetime]::ParseExact($ExifDtString,“yyyy:MM:dd HH:mm:ss`0”,$Null)
}
Catch {
Write-Warning “Problem reading Exif DateTaken string in $ImageFile ($_)”
# Only continue if an absolute time was specified…
#Todo: Add an absolute parameter and a parameter-set
# If ($Absolute) {Continue} Else {Break}
$Img.Dispose();
$FileStream.Close()
Break
}

Write-Verbose “Extracted EXIF infomation from $ImageFile
Write-Verbose “Original Time is $($OldTime.ToString(‘F’))

Try {
# Convert the time by adding the offset
$NewTime=$OldTime.Add($Offset)
}
Catch {
Write-Warning “Problem with time offset $Offset ($_)”
$Img.Dispose()
$FileStream.Close()
Break
}

# Convert to a string, changing slashes back to colons in the date.  Include trailing 0x00…
$ExifTime=$NewTime.ToString(“yyyy:MM:dd HH:mm:ss`0”)

Write-Verbose “New Time is $($NewTime.ToString(‘F’)) (Exif: $ExifTime)”

#endregion

# Overwrite the EXIF DateTime property in the image and set
$ExifDT.Value=[Byte[]][System.Text.Encoding]::ASCII.GetBytes($ExifTime)
$Img.SetPropertyItem($ExifDT)

# Create a memory stream to save the modified image…
$MemoryStream=New-Object System.IO.MemoryStream

Try {
# Save to the memory stream then close the original objects
# Save as type $Img.RawFormat  (Usually [System.Drawing.Imaging.ImageFormat]::JPEG)
$Img.Save($MemoryStream, $Img.RawFormat)
}
Catch {
Write-Warning “Problem modifying image $ImageFile ($_)”
$MemoryStream.Close(); $MemoryStream.Dispose()
Break
}
Finally {
$Img.Dispose()
$FileStream.Close()
}

# Update the file (Open with Create mode will truncate the file)

If ($PSCmdlet.ShouldProcess($ImageFile,‘Update EXIF DateTaken’)) {
Try {
$Writer = New-Object System.IO.FileStream($ImageFile, [System.IO.FileMode]::Create)
$MemoryStream.WriteTo($Writer)
}
Catch {
Write-Warning “Problem saving to $OutFile ($_)”
Break
}
Finally {
If ($Writer) {$Writer.Flush(); $Writer.Close()}
$MemoryStream.Close(); $MemoryStream.Dispose()
}
}
# Finally, if requested, decorate the path object with the EXIF dates and pass it on…

If ($PassThru) {
$PathItem |
Add-Member -MemberType NoteProperty -Name ExifDateTaken -Value $NewTime -PassThru |
Add-Member -MemberType NoteProperty -Name ExifOriginalDateTaken -Value $OldTime -PassThru
}

} # End Foreach Path

} # End Process Block

End
{
# There is no end processing…
}

} # End Function

11 thoughts on “Modify Date Taken values on Photos with PowerShell, the Update-ExifDateTaken script cmdlet”

  1. Great set of scripts. This allowed me to fix the times between my two cameras that I took on vacation so I could create an integrated set of images. Thanks!

  2. Yes, this is excellent, thank you. For noobs like me, you can use the “new-timespan” cmdlet to figure out what your offset value should be, and store it in a variable to make it easier.

  3. I got married last week and when I started asked for everyone’s photos I realised that all of their cameras had different dates and times. I wanted to get them all into one folder so I could see them all in a single timeline so I ran the script with the basic OffSet setting and all of the files were fixed!
    Thank you! Great script, easy to understand.

  4. Hi Chris. I just got back from our blated honeymoon and had the same requirement from the Wedding – I wanted to combine the two camera’s conents into one folder.

    One quick question – I’m trying to rename files but because I’ve used the camera’s “burst” feature, a lot of photos have failed to rename. If I can somehow keep the original filename in the -newname then I don’t think I’d have that issue.

    However when I tried this command, I get an error.

    gci *.jpg|
    Get-ExifDateTaken |
    Rename-Item -NewName {“Holiday {0:yyy-MM-dd HH.mm.ss} ($_.BaseName).jpg” -f $_.ExifDateTaken, (Split-Path (Split-Path $_) -Leaf)}

    #e.g. of original file names are like IMAG0498, IMAG0499….. and I wanted the output from above to create something like “2014-11-10 16.54.58 (IMAG0498).jpg”

    Can you think of any better ways of doing it?

    Cheers
    Tom

    1. Hi Tom,

      I’ve come across this problem myself – where the source files have the same date and time. The EXIF datetime is only recorded to the nearest second, so with burst mode photos it’s highly likely that the target name (based on the datetime) you end up specifying in the Rename-Item cmdlet will already exist. A simple workaround is to amend the code in the Rename-Item scriptblock to append an index to the name, maybe something like, “Holiday {0:xxxx}-$Index”. Start $Index at 1 and test to see if the resulting file name already exists – if it does, increment the index and repeat. Sorry this isn’t something I can handle in the code – the script is restricted by the 1 second precision in the EXIF datetime value.

      Hope this helps,
      Regards,
      Chris

  5. Thanks for the reply and the advice. Actually on Monday (after getting back to work and warming up my PowerShell brain I had another attempt at it but haven’t had time to reply back.

    I actually foreach’ed it and used your get function to get the time/date, sadly my coding skills leave a little to be desired, but it seemed to do the job.

    This way I could keep the old name within the name and luckly the two camera’s didn’t spit out a duplicate file name.

    Thanks again, your code still saved me hours/days of work!

    Cheers / Tom
    ———–
    $photos = gci *.jpg
    foreach ($photo in $photos) {
    $dateTaken = (Get-ExifDateTaken $photo).ExifDateTaken
    $oldName = $photo.BaseName
    $newName = “Honeymoon {0:yyyy-MM-dd HH-mm} ($oldName).jpg” -f $dateTaken
    Rename-Item $photo -NewName $newName
    $oldName + ” –> ” + $newName
    $dateTaken = “”
    $photo = “”
    }

  6. Very interesting topic, I have the problem need to change the photo date back to its original taking date.
    but the script in Microsoft one drive link is no long available

    1. Hi John, sorry the link is broken. Suggest you look on the PowerShell Gallery (powershellgallery.com) or Github for the latest version of the scripts. Let me know if you have any problems. Cheers, Chris

Leave a comment