Help us understand the problem. What is going on with this article?

画像ファイルのEXIFタグから撮影日時と位置情報を取得する際のTips

More than 3 years have passed since last update.

画像ファイルのEXIFタグ情報を扱う際のTipsを紹介します。

動作を確認した環境は以下の通りです。

  • Windows 7 (32bit)
  • PHP 5.6.16 ビルトインWebサーバ

EXIFタグについては以下の情報を参考とさせていただきました。

Data URIスキームを利用してバイナリからEXIFタグ情報を読み込む

PHPでEXIFタグ情報を取得するには exif_read_data() がよく使われると思いますが、この関数は引数としてファイルパスを指定することになっています。

しかし、この関数、実はData URI形式の文字列に変換してやれば普通に動きます。

通常、Data URIにはMIMEタイプが必要になりますが、今回の用途では "application/octet-stream" で問題ないです。

<?php

$url = 'http://cdn-ak.f.st-hatena.com/images/fotolife/k/k-holy/20121214/20121214072749.jpg';
$binary = file_get_contents($url);
$exif = exif_read_data(
    sprintf('data://application/octet-stream;base64,%s', base64_encode($binary)), null, true
);

exif_read_data() の第3引数をtrueに変えているのは単なる好みですが、以下、EXIFヘッダを扱うサンプルコードはこの形式での取得を前提とします。

ちなみに、この方法は getimagesizefromstring() 関数が使えないPHP5.3系以下の環境で同じことをやりたい場合にも応用できます。

<?php
$imagesize = getimagesize(
    sprintf('data://application/octet-stream;base64,%s', base64_encode($binary)), $imageinfo
);

EXIFタグから撮影日時を取得する時は書式とタイムゾーンに要注意

いわゆる「撮影日時」は Exif IFD の DateTimeOriginal ヘッダに設定されています。

前述のように exif_read_data() の第3引数にtrueを指定した場合は $exif['EXIF']['DateTimeOriginal'] と二次元配列を指定して取得します。

<?php

if (isset($exif['EXIF']['DateTimeOriginal'])) {

    $exifDatePattern = '/\A(?<year>\d{4}):(?<month>\d{1,2}):(?<day>\d{1,2}) (?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})\z/';

    if (preg_match($exifDatePattern, $exif['EXIF']['DateTimeOriginal'], $matches)) {
        $dateTime = new \DateTime(sprintf('%d-%d-%d %d:%d:%d',
            $matches['year'],
            $matches['month'],
            $matches['day'],
            $matches['hour'],
            $matches['minute'],
            $matches['second']
        ));
        echo $dateTime->format('Y-m-d H:i:s');
    }

}

要注意なのは、EXIFタグに設定されている日時の書式と、タイムゾーンです。

まず書式は YYYY:MM:DD HH:MM:SS というあまり見慣れない形式になってますので、アプリケーションで日時として扱うには変換が必要になると思います。

タイムゾーンについては、EXIFの規格上では TimeZoneOffset というヘッダが用意されているようですが、実際にはあまり普及していないようです。

撮影日時とタイムゾーンの実際については以下の記事が参考になります。(自分、スマートフォンとか持ってないんで…。)

大抵のJPEGファイルには、DateTime、DateTimeOriginal、DateTimeDigitizedの3つのタイムスタンプが埋め込まれていて、どれも同じ日時を指しています。写真が撮影された日時を示すのはDateTimeOriginalです。GIMPなどで写真を編集すると、DateTimeOriginalは保持してDateTimeが変更されます。

これらの日時情報にはタイムゾーン情報が無く、現地時間が保存されます。iPhoneやAndroid端末を持って国外へ行くと、現地でタイムゾーン情報を取得した以降に撮影した写真の日時は現地時間になります。東へ向かって旅行してタイムゾーンを跨ぐと、後に撮影した写真の日時が先の写真よりも前になるので注意が必要です。

スマートフォンでジオタグ有効で撮った写真にはGPSDateStampとGPSTimeStampというタグがあり、UTC時刻が保存されているのでタイムゾーンが判ります。

タイムゾーンを埋め込むためのTimeZoneOffsetというタグもあるのですが、TimeZoneOffsetを埋め込む機種は見たことがありません。

上記の通り、位置情報タグに GPSDateStamp や GPSTimeStamp といったGMTを扱うヘッダが用意されていますので、現状ではこれと DateTimeOriginal から時差を算出して補正するしかないでしょうか。

EXIFタグから位置情報(緯度/経度)を取得する時は「度分秒」からの変換が必要

緯度経度は GPS IFD の GPSLatitude, GPSLongitude ヘッダに設定されています。

前述のように exif_read_data() の第3引数にtrueを指定した場合は $exif['GPS']['GPSLatitude'] のように二次元配列を指定して取得します。

exif_read_data() では GPSLatitude, GPSLongitude の値はGoogleMaps等で使われる10進数ではなく、度分秒が分数の値で配列に格納されて返されます。

たとえば 35度48分9.86秒 の場合は ['35/1', '48/1', '986/100'] といった具合です。

これを10進数形式に変換するには、度分秒それぞれの値を数値に変換した後、度 + (分 / 60) + (秒 / 3600) で算出します。

たとえば ['35/1', '48/1', '986/100'] の場合は (35 / 1) + (48 / 1 / 60) + (986 / 100 / 3600) となります。

更に、GPSLatitudeRef (北緯 or 南緯), GPSLongitudeRef (東経 or 西経) ヘッダの値を見て、南緯/西経の場合は負の値にします。

<?php

if (isset($exif['GPS']['GPSLatitudeRef']) && isset($exif['GPS']['GPSLatitude']) &&
    isset($exif['GPS']['GPSLongitudeRef']) && isset($exif['GPS']['GPSLongitude'])
) {

    // 度 + 分 + 秒に分割されている値を正規化
    $normalizeValues = function ($values, $minus) {
        if (isset($values[0]) && isset($values[1]) && isset($values[2])) {
            $normalizeValue = function ($value) {
                $val = explode('/', $value);
                return (isset($val[0]) && ctype_digit($val[0]) && isset($val[1]) && ctype_digit($val[1]))
                    ? floatval($val[0]) / floatval($val[1])
                    : null;
            };
            $deg = $normalizeValue($values[0]);
            $min = $normalizeValue($values[1]);
            $sec = $normalizeValue($values[2]);
            if ($deg !== null && $min !== null && $sec !== null) {
                $point = $deg + ($min / 60) + ($sec / 3600);
                return ($minus) ? $point * -1 : $point;
            }
        }
        return null;
    };

    $latitude = $normalizeValues($exif['GPS']['GPSLatitude'], $exif['GPS']['GPSLatitudeRef'] === 'S');
    $longitude = $normalizeValues($exif['GPS']['GPSLongitude'], $exif['GPS']['GPSLongitudeRef'] === 'W');

    // 右手系(経度, 緯度)で出力
    if ($latitude !== null && $longitude !== null) {
        echo sprintf('%s %s', $longitude, $latitude);
    }

}

右手系というのは緯度と経度を並べる順序のことです。

右手系では:(経度、緯度、及び高度)の順とする。
これに対して左手系では:(緯度、経度、及び高度)の順とする。

GoogleMaps APIでは左手系が採用されていますが、PostGISやMySQLなど多くのDBMSでは右手系となってます。MySQLのGEOMETRY型で管理する場合は要注意。

他には測地系の違いなどもありますが、最近は日本でもGPSでも採用されている世界測地系(WGS84)が普及し、あまり気にしなくても良くなってきたみたい。

※いわゆるガラケーの時代は、取得方法、表記、測地系がキャリア毎、端末の世代毎に違ってて、ちょっと面倒でした…。

XMPメタデータが設定されているファイル等で exif_read_data() が警告を発生する可能性がある

XPM (Extensible Metadata Platform) というのはAdobe社のアプリケーションで利用されているXML形式のメタデータです。

どういった用途に使われているかは置いといて、とにかくこれがJPEGファイルに埋め込まれているケースがあって、困ったことに exif_read_data() が警告 (E_WARNING) を発生させる可能性があるのです。

幸い、警告は発生してもEXIFタグ情報の取得には問題ないので、一時的にエラー出力レベルを変更して対応するという方法が使えます。

<?php

$errorLevel = error_reporting();
$errorLevelChanged = ($errorLevel & E_WARNING);
if ($errorLevelChanged) {
    error_reporting($errorLevel ^ E_WARNING);
}
$exif = exif_read_data($filepath, null, true);
if ($errorLevelChanged) {
    error_reporting($errorLevel);
}

こういう感じで実行時のエラーレベルを取得して一時的に E_WARNING を無効にした後、元に戻します。

exif_read_data() に限らず、外部入力値を扱う関数などで警告が発生する可能性を排除できない場合に、色々と応用できる方法です。

(コードの意図がより明確になるという意味で、エラー制御演算子よりもこちらの方が好ましいと思います)

バイナリからMIMEタイプを判別する

前述したEXIFタグ情報の読み取りには正確なMIMEタイプは不要でしたが、参考までにMIMEタイプの判別方法をまとめておきます。

  • exif_imagetype()
    指定した画像ファイルの先頭バイトを読み、IMAGETYPE定数の値を返す関数です。
    これに image_type_to_mime_type() を組み合わせます。ただし、引数はファイルパスのみのようです。

  • getimagesize()
    画像の大きさを返す関数ですが、IMAGETYPE定数の値も取得できます。ただし、マニュアルの説明によると exif_imagetype() の方が動作は速いようです。
    これも引数はファイルパスのみですが、実は exif_read_data() と同様にData URI形式の文字列でも動作しますので、バイナリから画像の大きさを取得したい時にこの方法が使えると思います。

  • mime_content_type()
    指定した画像ファイルのMIMEタイプを文字列で返す関数です。これも引数はファイルパスのみのようです。

※確か以前は「非推奨」と記載されてましたが、PHP7にも入っているようですし、大丈夫なのかな。

  • finfo クラス FILEINFO_MIME オプションを指定したfinfoオブジェクトでは、MIMEタイプを文字列で取得することができます。 ファイルパスを引数に取る finfo::file() がよく知られていますが、バイナリを引数に取る finfo::buffer() もあります。

先ほどのバイナリからEXIFタグ情報を読み込むコードでこの関数を使う場合、こうなりますね。

<?php

$url = 'http://cdn-ak.f.st-hatena.com/images/fotolife/k/k-holy/20121214/20121214072749.jpg';
$binary = file_get_contents($url);
$fileInfo = new \finfo(FILEINFO_MIME);
$exif = exif_read_data(
    sprintf('data://%s;base64,%s', $fileInfo->buffer($binary), base64_encode($binary), null, true
));

※今回の用途では application/octet-stream 決め打ちで問題ないので、遅くなるだけです。念のため。

GPS端末を持ってなくても大丈夫!GeoSetterで画像ファイルに位置情報を埋め込む方法

ついでに、スマートフォンのような高級端末は持っていない人のために、Windowsで簡単に位置情報を埋め込めるソフトウェアを紹介します。

GoogleMapsの地図を検索、マーカーを操作して「お気に入り」に座標を保存、データ編集(Ctrl+E)で「お気に入り」から位置情報を読み込み保存、という手順ですることができます。

嬉しいことに日本語対応されてます!

今回は単一ファイルに位置情報を埋め込む機能のみ使いましたが、複数のファイルから位置情報を抽出して地図にマーカーを配置したり、GPSロガーのデータファイルを読み込んで同期、地図上に軌跡を表示する機能などもあるみたいです。

もちろん位置情報以外にも、色々な情報を表示/編集できます。

※内部的には ExifTool というコマンドラインツールを利用しているらしいです。

スマートフォンを持っていない自分も、これでテストデータを作成できました。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした