Edited at

スマートフォンから送信された写真のリサイズとEXIFタグのOrientation値(Intervention Image利用例)

More than 1 year has passed since last update.

今更ながら、スマートフォンから送信された写真のリサイズ処理に際して、EXIFタグのOrientationを扱うことがありました。

EXIFタグのOrientationは、カメラで撮影した時に画像の向きに関する情報をファイルに埋め込むことで、アプリケーション側で表示する際にその情報を見て向きを補正するための仕様です。

しかし、これが設定された画像ファイルをサーバ側で受け取って縮小する際、GD等による加工の過程でEXIFタグの情報が失われてしまうために、画像の向きが補正できなくなってしまうわけです。

なので、まず exif_read_data() を使ってEXIFのOrientation値を読み込み、それに併せてGD関数で画像を反転/回転させた後、縮小します。

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


  • Windows 10 (64bit)

  • PHP 5.6.31 ビルトインWebサーバ

以下、説明を分かりやすくするため、「上下反転」「左右反転」「角度を指定して回転」の3つの関数を定義します。


GD関数で上下反転/左右反転

PHP5.5からは imageflip() という便利関数が追加されてるので、それを使えばOKなのですが…。

残念ながら5.4系や5.3系以下のPHPを使わざるを得ない場合は、変更したい向きに合わせたサイズのGDイメージを作り直す方法があります。

<?php

// 上下反転
function flip($src)
{
if (function_exists('imageflip')) {
imageflip($src, IMG_FLIP_VERTICAL);
return $src;
}
$width = imagesx($src);
$height = imagesy($src);
$dst = imagecreatetruecolor($width, $height);
if (imagecopyresampled($dst, $src, 0, 0, 0, $height - 1, $width, $height, $width, $height * -1)) {
return $dst;
}
throw new \RuntimeException();
}

// 左右反転
function flop($src)
{
if (function_exists('imageflip')) {
imageflip($src, IMG_FLIP_HORIZONTAL);
return $src;
}
$width = imagesx($src);
$height = imagesy($src);
$dst = imagecreatetruecolor($width, $height);
if (imagecopyresampled($dst, $src, 0, 0, $width - 1, 0, $width, $height, $width * -1, $height)) {
return $dst;
}
throw new \RuntimeException();
}

関数名はImageMagickのコマンドオプションからいただきました。

imagecopyresampled() による反転はこちらの記事も参考にさせていただきました。


GD関数で角度を指定して回転

こちらはPHP5なら関数一つで大丈夫です。

<?php

// 回転
function rotate($src, $angle)
{
return imagerotate($src, $angle, 0, 0);
}

imagerotate() の第3引数は回転後に余白ができた場合の色なので、透過処理が必要な場合は要注意です。また、第4引数に0以外を指定すると透過色を無視します。

(今回の例ではJPEGなので関係ありませんが)


GD関数でOrientation値に合わせて向きを回転

Orientation値の見方についてはWeb上にも色々な表が出回っており、解釈も難しく混乱してしまうのですが…。

一応、こちらに記載の仕様が正しいのではないかと思います。


Orientation = 1 は、Exif画像ファイルに保存されたデータの行0と、表示画面でのvisual topを、列0とvisual leftを、それぞれ一致させて表示する場合に記録する。

Orientation = 2 は、Orientation = 1 を左右反転したものに相当する。

Orientation = 3 は、Orientation = 6 を時計回りに90度回転したものに相当する。

Orientation = 4 は、Orientation = 3 を左右反転したものに相当する。

Orientation = 5 は、Orientation = 6 を左右反転したものに相当する。

Orientation = 6 は、Orientation = 1 を時計回りに90度回転したものに相当する。

Orientation = 7 は、Orientation = 8 を左右反転したものに相当する。

Orientation = 8 は、Orientation = 3 を時計回りに90度回転したものに相当する。


しかし、頭の悪い自分には難しすぎる…何のクイズだよと思ってしまいます…要は「左右反転」と「時計回りに90度回転」の組み合わせで全部いける、ということなんでしょうけど…。

それよりも、よく使われているライブラリの実装を確認するのが手っ取り早いだろうってことで、Laravelフレームワークのプラグインにも採用されているという Intervention Image のソースを確認してみました。

<?php

switch ($image->exif('Orientation')) {
case 2:
$image->flip();
break;
case 3:
$image->rotate(180);
break;
case 4:
$image->rotate(180)->flip();
break;
case 5:
$image->rotate(270)->flip();
break;
case 6:
$image->rotate(270);
break;
case 7:
$image->rotate(90)->flip();
break;
case 8:
$image->rotate(90);
break;

Androidのカメラアプリから出力された画像でOrientation値に0が設定されるという罠を経験しましたので、それも加えて前述の関数を組み合わせると、こんな対応で良さそうです。

<?php

// 向きに合わせて回転/反転
function orientate($src, $orientation)
{
switch ($orientation) {
case 0:
case 1:
return $src;
case 2:
return flip($src);
case 3:
return rotate($src, 180);
case 4:
return flip(rotate($src, 180));
case 5:
return flip(rotate($src, 270));
case 6:
return rotate($src, 270);
case 7:
return flip(rotate($src, 90));
case 8:
return rotate($src, 90);
}
throw new \RuntimeException();
}

以下は、縦向きにして使う端末 iPod Touch で撮影した画像をそのままアップロードして exif_read_data() で調べた値です。


  • 普通に撮影 → 6

  • 上下逆にして撮影 → 8

  • 左に倒して撮影 → 1

  • 右に倒して撮影 → 3

どうやら本体のカメラで撮影した時の写真の向きと、画面の向きは一致しているわけではないようです。

手元で確認可能なのが iPod Touch のみだったので、他の端末の場合はどうなのか分かりません。(自分、スマートフォンとか持ってないんで…。)


EXIFタグのOrientation値に合わせて画像を回転/反転した後、縮小するサンプルコード:GD関数版

iPod Touch で向きを変えながら撮影した4枚の写真 orientation(1).jpg, orientation(3).jpg, orientation(6).jpg, orientation(8).jpg を用意して、以下のようなスクリプトでテストしました。(エラー処理やGDイメージの破棄など入れていません…)

今回の主題とはあまり関係ないのですが、GD関数で引数で指定された最大サイズに合わせて縦横比を保ちつつ縮小する処理も関数にしています。

<?php

// 上下反転
function flip($src)
{
if (function_exists('imageflip')) {
imageflip($src, IMG_FLIP_VERTICAL);
return $src;
}
$width = imagesx($src);
$height = imagesy($src);
$dst = imagecreatetruecolor($width, $height);
if (imagecopyresampled($dst, $src, 0, 0, 0, $height - 1, $width, $height, $width, -$height)) {
return $dst;
}
throw new \RuntimeException();
}

// 左右反転
function flop($src)
{
if (function_exists('imageflip')) {
imageflip($src, IMG_FLIP_HORIZONTAL);
return $src;
}
$width = imagesx($src);
$height = imagesy($src);
$dst = imagecreatetruecolor($width, $height);
if (imagecopyresampled($dst, $src, 0, 0, $width - 1, 0, $width, $height, -$width, $height)) {
return $dst;
}
throw new \RuntimeException();
}

// 回転
function rotate($src, $angle)
{
return imagerotate($src, $angle, 0, 0);
}

// 向きに合わせて回転/反転
function orientate($src, $orientation)
{
switch ($orientation) {
case 0:
case 1:
return $src;
case 2:
return flip($src);
case 3:
return rotate($src, 180);
case 4:
return flip(rotate($src, 180));
case 5:
return flip(rotate($src, 270));
case 6:
return rotate($src, 270);
case 7:
return flip(rotate($src, 90));
case 8:
return rotate($src, 90);
}
throw new \RuntimeException();
}

// 縮小
function resize($src, $maxWidth, $maxHeight)
{
$srcWidth = imagesx($src);
$srcHeight = imagesy($src);
$ratioW = $maxWidth / $srcWidth;
$ratioH = $maxHeight / $srcHeight;
$dstWidth = ($ratioW < $ratioH) ? $maxWidth : (int)max(1, floor($srcWidth * $ratioH));
$dstHeight = ($ratioW < $ratioH) ? (int)max(1, floor($srcHeight * $ratioW)) : $maxHeight;
$dst = imagecreatetruecolor($dstWidth, $dstHeight);
if (imagecopyresampled($dst, $src, 0, 0, 0, 0, $dstWidth, $dstHeight, $srcWidth, $srcHeight)) {
return $dst;
}
throw new \RuntimeException();
}

$orientation = (isset($_GET['o'])) ? (int)$_GET['o'] : 1;
$maxSize = (isset($_GET['s'])) ? (int)$_GET['s'] : 200;

$srcFilename = sprintf('orientation(%d).jpg', $orientation);
$dstFilename = sprintf('orientation(%d).%d.jpg', $orientation, $maxSize);

$srcFile = __DIR__ . DIRECTORY_SEPARATOR . $srcFilename;
if (!file_exists($srcFile)) {
throw new \RuntimeException();
}

$dstFile = __DIR__ . DIRECTORY_SEPARATOR . $dstFilename;

$errorLevel = error_reporting();
$errorLevelChanged = ($errorLevel & E_WARNING);
if ($errorLevelChanged) {
error_reporting($errorLevel ^ E_WARNING);
}

$exif = exif_read_data($srcFile, null, true);

if ($errorLevelChanged) {
error_reporting($errorLevel);
}

if (isset($exif['IFD0']['Orientation'])) {
$srcImage = imagecreatefromjpeg($srcFile);
$dstImage = resize(orientate($srcImage, $exif['IFD0']['Orientation']), $maxSize, $maxSize);
if (is_resource($dstImage)) {
imagejpeg($dstImage, $dstFile, 80);
$dstExif = exif_read_data($dstFile, null, true);
}
}
?>
<?php if (is_array($exif)) : ?>
<pre><?php print_r($exif) ?></pre>
<?php endif ?>
<img src="<?= $dstFilename ?>"/>
<?php if (isset($dstExif) && is_array($dstExif)) : ?>
<pre><?php print_r($dstExif) ?></pre>
<?php endif ?>

GDイメージをファイル出力した後でEXIFの値がどう変化するかを確認したところ、 exif_read_data() の第3引数をtrueに設定した場合の戻り値で、元ファイルにあった ANY_TAG, IFD0, EXIF セクションは全て削除され、COMMENT セクションに "CREATOR: gd-jpeg v1.0 (using IJG JPEG v90), quality = 80" という値が追加されていました。

FILE, COMPUTED セクションは画像ファイルの中身を見て自動的に設定される値のようなので、それぞれ変換後の内容に変わっています。


EXIFタグのOrientation値に合わせて画像を回転/反転した後、縮小するサンプルコード:Intervention Image版

すでにライブラリの名前を出しましたが、GD関数で頑張るよりも、程良く抽象化された画像処理ライブラリの Intervention Image を使った方が早いし、ソースも分かりやすく書けると思います。

フィルタや描画系の機能などは個人的には使う機会はないのですが、ファイルパスやバイナリデータはもちろん、GDリソースやストリームまで様々な形式に対応していて、単にデータのフォーマット変換や軽量化だけでも使い勝手が良いです。

設定値を変更するだけで、抽象化されたクラスの内部でGD関数とImagickクラスを切り替えてくれる機能もあります。

そんなわけで、先ほどのテストスクリプトを Intervention Image で書き換えると、こんな感じになります。

GDリソースを触るコードが綺麗に無くなっているところがポイントです。ついでにIMG要素のSRC属性値もData URIにしてみました。また、exif_read_data() はData URIから読み込むこともできるので、比較のためにそのEXIFも併せて出力しています。

<?php

include __DIR__ . '/path/to/vendor/autoload.php';

$orientation = (isset($_GET['o'])) ? (int)$_GET['o'] : 1;
$maxSize = (isset($_GET['s'])) ? (int)$_GET['s'] : 200;

$srcFilename = sprintf('orientation(%d).jpg', $orientation);
$dstFilename = sprintf('orientation(%d).%d.jpg', $orientation, $maxSize);

$srcFile = __DIR__ . DIRECTORY_SEPARATOR . $srcFilename;
if (!file_exists($srcFile)) {
throw new \RuntimeException();
}

$dstFile = __DIR__ . DIRECTORY_SEPARATOR . $dstFilename;

$errorLevel = error_reporting();
$errorLevelChanged = ($errorLevel & E_WARNING);
if ($errorLevelChanged) {
error_reporting($errorLevel ^ E_WARNING);
}

$exif = exif_read_data($srcFile, null, true);

if ($errorLevelChanged) {
error_reporting($errorLevel);
}

$imageManager = new \Intervention\Image\ImageManager();
$image = $imageManager->make($srcFile);
$image->orientate();
$image->resize(
($image->width() > $image->height()) ? $maxSize : null,
($image->width() > $image->height()) ? null : $maxSize,
function ($constraint) {
$constraint->aspectRatio();
}
);
$image->save($dstFile, 80);

$dstExif = exif_read_data($dstFile, null, true);

$dataUri = $image->encode('data-url', 80);
$dataUriExif = exif_read_data($dataUri, null, true);
?>
<?php if (is_array($exif)) : ?>
<pre><?php print_r($exif) ?></pre>
<?php endif ?>
<img src="<?= $dataUri ?>"/>
<?php if (is_array($dstExif)) : ?>
<pre><?php print_r($dstExif) ?></pre>
<?php endif ?>
<?php if (is_array($dataUriExif)) : ?>
<pre><?php print_r($dataUriExif) ?></pre>
<?php endif ?>

横幅と高さの最大値に合わせた縮小は resize($width, $height, $callback) メソッドで、サイズを可変にしたい方の引数にNULLを指定しつつ、第3引数に上記のような指定を行うことで実現できます。

EXIF情報がどう書き換わるのか確認のため exif_read_data() は残していますが、単に画像の向きを変えて縮小するだけなら、これも必要ありません。

出力したJPEGファイルのEXIFを確認したところ、前述のコードと全く同じ結果になりました。

なお、ここでは前述のコードと条件を揃えるために save() の第2引数 (quality) に80を指定していますが、デフォルト値は90なので省略すると結果が異なってしまいます。

encode() メソッドも同様で、第2引数がqualityの指定になっていてデフォルト値が90のため、同じく80を指定しています。これでData URIから読み込んだEXIFも、だいたい同じ値になることが確認できましたが、ファイルとして存在しないためか FILE.FileName と FILE.FileDateTime の値だけがおかしな値になりました。

こういう風に使い勝手の良いライブラリなので、自分も最近ようやく、長年育ててきたGD関数ベースの自作ライブラリからこれに乗り換えました。

PHPでEXIFタグを扱うサンプルは、こちらの記事も併せてどうぞ。