2015/02/12

Exifの日付だけを修正する

先日、新しいカメラを持ってイベントに行って写真を撮ってきた。まではよかったが、PCに取り込む段になって写真の日付が1年前になっていることに気づいた。原因は容易に想像できるとおり、初めにカメラの時計を設定したときに年を間違えていた。

当然、画像ファイルのExifに記録された日付も1年前になっていたが、Exifの情報は色々な写真管理の基礎となるので、これはいかにもまずい。

そこで既存ツールを試してみたが、Exifのサムネイルが維持されなかったりして、ツールを確かめて回るのも手間。それなら自分でできないかと思い、ただし、真っ当にExifを編集しようとするとファイルサイズが変わるので、年を1年直すだけでそれは負けた気がする、ということで直接ファイルのバイナリを修正しようと考えた。

Exifの規格では日時は"YYYY:MM:DD HH:MM:SS"のフォーマットのASCII文字列と決まっているので、そんな無理な話でもないはず。実際、バイナリエディタで簡単に見ることができたので、ゴーアヘッド。

1. バイト配列用のIndexOfとReplaceメソッド


まずはバイト配列に含まれる他のバイト配列の位置を調べて置換するメソッドが必要なので(StringにおけるString.IndexOfとString.Replaceメソッドに相当)、書いてみた。

主眼は位置を調べるメソッドの方で、複数用(SequenceIndicesOf)と単数用(SequenceIndexOf)にそれぞれ引数がbyte[]とIEnumerable<byte>のオーバーロードを作った。
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
public static class BytesExtension
{
public static byte[] SequenceReplace(this byte[] source, byte[] oldValue, byte[] newValue, int maxCount = -1)
{
var sourceIndices = SequenceIndicesOf(source, oldValue, maxCount).ToArray();
if (!sourceIndices.Any())
return source;
Debug.WriteLine("Indices: {0} ({1})", String.Join(", ", sourceIndices), sourceIndices.Length);
var destination = new byte[source.Length + (newValue.Length - oldValue.Length) * sourceIndices.Length];
// Copy source before old value.
var sourceIndexFirst = sourceIndices.First();
if (0 < sourceIndexFirst)
{
Buffer.BlockCopy(source, 0, destination, 0, sourceIndexFirst);
}
for (int i = 0; i < sourceIndices.Length; i++)
{
var sourceIndex = sourceIndices[i];
var destinationIndex = sourceIndex + (newValue.Length - oldValue.Length) * i;
// Copy new value.
Buffer.BlockCopy(newValue, 0, destination, destinationIndex, newValue.Length);
// Copy source after new value before next old value.
var sourceOffset = sourceIndex + oldValue.Length;
var sourceOffsetNext = (i < sourceIndices.Length - 1) ? sourceIndices[i + 1] : source.Length;
Buffer.BlockCopy(
source,
sourceOffset,
destination,
destinationIndex + newValue.Length,
sourceOffsetNext - sourceOffset);
}
return destination;
}
// Multiple indices by byte[]
public static IEnumerable<int> SequenceIndicesOf(this byte[] source, byte[] value, int maxCount = -1)
{
int count = 0;
int startIndex = 0;
while (true)
{
var index = source.SequenceIndexOf(value, startIndex);
if (index < 0)
yield break;
yield return index;
count++;
if ((0 <= maxCount) && (maxCount <= count))
yield break;
startIndex = index + value.Length;
}
}
// Multiple indices by IEnumerable<byte>
public static IEnumerable<int> SequenceIndicesOf(this IEnumerable<byte> source, IEnumerable<byte> value, int maxCount = -1)
{
int count = 0;
var valueBytes = value as byte[] ?? value.ToArray();
int valueIndex = 0;
int valueIndexLast = valueBytes.Length - 1;
int sourceIndex = -1; // -1 is to make it 0 at the first loop.
foreach (var sourceByte in source)
{
sourceIndex++;
if (sourceByte != valueBytes[valueIndex])
{
valueIndex = 0;
continue;
}
if (valueIndex < valueIndexLast)
{
valueIndex++;
continue;
}
yield return sourceIndex - valueIndexLast;
count++;
if ((0 <= maxCount) && (maxCount <= count))
yield break;
valueIndex = 0;
}
}
// Single index by byte[]
public static int SequenceIndexOf(this byte[] source, byte[] value, int startIndex = 0)
{
int valueIndex = 0;
int valueIndexLast = value.Length - 1;
for (int sourceIndex = startIndex; sourceIndex < source.Length; sourceIndex++)
{
if (source[sourceIndex] != value[valueIndex])
{
valueIndex = 0;
continue;
}
if (valueIndex < valueIndexLast)
{
valueIndex++;
continue;
}
return sourceIndex - valueIndexLast;
}
return -1;
}
// Single index by IEnumerable<byte>
public static int SequenceIndexOf(this IEnumerable<byte> source, IEnumerable<byte> value, int startIndex = 0)
{
var valueBytes = value as byte[] ?? value.ToArray();
int valueIndex = 0;
int valueIndexLast = valueBytes.Length - 1;
int sourceIndex = startIndex - 1; // -1 is to make it startIndex at the first loop.
foreach (var sourceByte in source.Skip(startIndex))
{
sourceIndex++;
if (sourceByte != valueBytes[valueIndex])
{
valueIndex = 0;
continue;
}
if (valueIndex < valueIndexLast)
{
valueIndex++;
continue;
}
return sourceIndex - valueIndexLast;
}
return -1;
}
}
ルートとしては、
  1. 複数用byte[]メソッド+単数用byte[]メソッド
  2. 複数用byte[]メソッド+単数用IEnumerable<byte>メソッド
  3. 複数用IEnumerable<byte>メソッド
の3通りあるが、SequenceReplaceメソッド中の呼び出し元の引数がbyte[]なので、このままだと1のルートが走る。

折角なので引数にAsEnumerable<byte>()を付けてルートを変えつつ、最終的なコードで300のJPGファイル(計1.06GiB)を処理したところ、所要時間は以下のようになった。
  1. 21.6秒
  2. 46.7秒
  3. 36.1秒
byte[]で通した1のルートが基本にして最速という結果だが、2のルートが2倍以上遅いのはSkipが時間を食っているのではないかと思う(実行の度に先頭からループが回るわけで)。自分的に期待したのは3のルートだが、1のルートには全く及ばず。

なお、最終的なコードでは置換数=検索数の上限を指定するmaxCount引数を使ってないが、実はヒット回数は各ファイル3回でかつ位置は先頭部分と分かっているので、これに3を指定すれば画像データ部分を無駄に検索する必要がなくなって所要時間はたいして変わらなくなる、というオチ。

[修正]

複数用IEnumerable<byte>メソッドのmaxCountの処理にバグがあったので修正した。複数用byte[]メソッドは問題なかったが、比較のためこれに合わせた。なお、数字を挙げた所要時間を計った際にはmaxCountを使ってないので、影響はない。

2. Exifの日付部分を置換するメソッド


このSequenceReplace拡張メソッドを使ってJPGファイルのExifの日付部分を置換するメソッド(とそのコンソールアプリ)。

各ファイルについて、以下の処理をする。
  1. バイト配列として読み込む
  2. 一応、真っ当にExifの日時の文字列を読み出す(System.Photo.DateTakenにあるパスを使う)
  3. 元の日時をDateTimeに変換し、修正したDateTimeを生成して、これを規格に従った文字列にする
  4. 両方の文字列をASCIIの文字コードでバイト配列に変換する
  5. これらを置換したバイト配列を生成する
  6. バイト配列を別ファイルに書き込む
// Add reference to WindowsBase and System.Xaml.
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Media.Imaging;
public class ExifDate
{
public static async Task ChangeFolderAsync(string sourceFolderPath, string destinationFolderPath, TimeSpan changeSpan)
{
if (!Directory.Exists(sourceFolderPath))
{
Debug.WriteLine("Source folder is not found.");
return;
}
if (!Directory.Exists(destinationFolderPath))
Directory.CreateDirectory(destinationFolderPath);
var fileNames = Directory.EnumerateFiles(sourceFolderPath)
.Where(x => Path.GetExtension(x).Equals(".jpg", StringComparison.OrdinalIgnoreCase))
.Select(x => Path.GetFileName(x));
foreach (var fileName in fileNames)
{
if (await ChangeFileAsync(
Path.Combine(sourceFolderPath, fileName),
Path.Combine(destinationFolderPath, fileName),
changeSpan))
continue;
Debug.WriteLine("Failed. " + fileName);
return;
}
Debug.WriteLine("Completed.");
}
private static async Task<bool> ChangeFileAsync(string sourceFilePath, string destinationFilePath, TimeSpan changeSpan)
{
const string dateFormat = "yyyy:MM:dd HH:mm:ss"; // The date and time format is "YYYY:MM:DD HH:MM:SS".
var sourceBytes = await Task.Run(() => File.ReadAllBytes(sourceFilePath)).ConfigureAwait(false);
string originalString;
using (var ms = new MemoryStream(sourceBytes))
originalString = ReadExifDateTaken(ms);
DateTime originalDate;
if (!DateTime.TryParseExact(originalString, dateFormat, null, DateTimeStyles.None, out originalDate))
return false;
var changedDate = originalDate.Add(changeSpan);
var changedString = changedDate.ToString(dateFormat);
Debug.WriteLine("Original " + originalString);
Debug.WriteLine("Changed " + changedString);
var originalBytes = Encoding.ASCII.GetBytes(originalString);
var changedBytes = Encoding.ASCII.GetBytes(changedString);
if (originalBytes.Length != changedBytes.Length)
return false;
var destinationBytes = sourceBytes.SequenceReplace(originalBytes, changedBytes);
if (sourceBytes.Length != destinationBytes.Length)
return false;
for (int i = 0; i < sourceBytes.Length; i++)
{
if (sourceBytes[i] == destinationBytes[i])
continue;
Debug.WriteLine("Position: {0} (0x{0:X4}) Value: {1} -> {2}",
i,
BitConverter.ToString(new[] { sourceBytes[i] }),
BitConverter.ToString(new[] { destinationBytes[i] }));
}
await Task.Run(() => File.WriteAllBytes(destinationFilePath, destinationBytes));
return true;
}
private static string ReadExifDateTaken(Stream source)
{
const string dateTakenQuery = "/app1/ifd/exif/{ushort=36867}";
var decoder = BitmapDecoder.Create(source, BitmapCreateOptions.DelayCreation, BitmapCacheOption.None);
if (!decoder.CodecInfo.FileExtensions.ToLower().Contains("jpg"))
return null;
if ((decoder.Frames[0] == null) || (decoder.Frames[0].Metadata == null))
return null;
var metadata = decoder.Frames[0].Metadata.Clone() as BitmapMetadata;
if (metadata == null)
return null;
if (!metadata.ContainsQuery(dateTakenQuery))
return null;
return metadata.GetQuery(dateTakenQuery).ToString();
}
}
view raw ExifDate.cs hosted with ❤ by GitHub
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static void Main(string[] args)
{
if (!Debugger.IsAttached)
Debug.Listeners.Add(new TextWriterTraceListener(Console.Out));
ChangeExifDateAsync(args).Wait();
}
static async Task ChangeExifDateAsync(string[] args)
{
if (args.Length < 3)
return;
var sourceFolderPath = args[0];
var destinationFolderPath = args[1];
int num;
if (!int.TryParse(args[2], out num))
return;
var changeSpan = TimeSpan.FromDays(num);
Debug.WriteLine("Started. {0} -> {1} [{2}]", sourceFolderPath, destinationFolderPath, changeSpan);
var sw = new Stopwatch();
sw.Start();
try
{
await ExifDate.ChangeFolderAsync(sourceFolderPath, destinationFolderPath, changeSpan);
}
catch (Exception ex)
{
Debug.WriteLine(ex);
}
sw.Stop();
Debug.WriteLine("Ended ({0:f3} sec).", sw.Elapsed.TotalSeconds);
}
}
view raw Program.cs hosted with ❤ by GitHub

3. まとめ


以上、実用での使用時間は1分以内だったというコーディングだが、バイト配列の置換というのはユーティリティメソッドとしていかにもありそうなので、より効率的な方法があるような気がする。

0 コメント :