ちょっと古いPCや安物のデバイスでJPEGを表示させようとすると、上下が反転していたりします。Exifに記録されている回転情報を反映していない場合に起きる現象なのですが、今回はPython3でこれを修正してみます。
もくじ:
はじめに
カメラで撮影した画像ファイルは、単純に正置方向に画素を並べているのではなく、
カメラが検出した方向をメタデータ(Exif)に記録しておき、人間向けには後でビューワーなどで表示を変換することを想定しています。
古いPCなど、Exifを読まないような環境ではExifにどんな値が入っていても正置方向に記録されているものとみなしてしまいます。ですので、要はExifに書かれている回転情報をもとに修正してしまえばいい、ということですね。
いくつかアプローチはあるようですが、比較的扱いやすいのはPillowモジュールで変換する方法です。画像処理の機械学習などにつかうモジュールで、Exifの編集機能(書き込み)はできませんが、取得・全削除はできるようになっています。
Python3のPillowはPython2のPILをフォークしたものらしいので、今回はPython3系に限定のお話です。
標準ライブラリにはない(Anacondaには同梱)ので、ない場合はpip
などでインストールします。
1 | > pip install Pillow |
今回のサンプルの全容は後ほど。サンプルを見た方が早いと思いますが、以下に解説しておきます。
Exifの取得
特定のディレクトリ以下にあるJPEGファイルを探し、各ファイル毎にPillowを使ってExifを取得します。
よくあるのはPIL.Image
オブジェクトの_getexif()
メソッドでdictとして取得する方法です。命名から解釈するとpublicなメソッドとして使ってほしくない意図が見えますが、まあPillow本来の使い方からは外れているのでしょう。
1 2 3 4 5 6 7 8 9 10 | # read exif with Image.open(file_path) as f: exif_ = f._getexif() # convert to readable dict info_ = {} for key_ in exif_.keys(): tag_ = TAGS.get(key_, key_) if tag_ in ["MakerNote", "UserComment"]: continue info_[tag_] = exif_[key_] |
タグ名はエンコードされており、ここではPIL.ExifTags.TAG
を使ってデコードしています。
また、取得できるものを全て取得してしまうと出力がやたらと長くなってしまうので、一部のユーザ情報のタグ["MakerNote", "UserComment"]
はスキップしています。
必須ではありませんが、あとでJSONファイルとして保存したいのでbytes型は各要素を辿って文字列にフォーマットしています。
1 2 3 4 5 6 7 8 9 10 | def _format_bytes(obj_): res = {} for key_, value_ in obj_.items(): if isinstance(value_, bytes): res[key_] = "{}".format(value_) elif isinstance(value_, dict): res[key_] = _format_bytes(value_) else: res[key_] = value_ return res |
手元の環境で試したところ、こんな感じのJSONが取得できました。
機種によって違いがありますが、GPSの情報やカメラの提供元などのメタデータが記録されていることがわかります。肝心の回転情報は"Orientation"
タグ(範囲は1~8)です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | [{ "path": "C:\\pathto\\images\\sample1.JPG", "file": "sample1.jpg", "exif": { ..., "GPSInfo": {"0": "...", "1": "N", "2": [...], ...}, ..., "Make": "Apple", "Model": "iPhone 6s", "Software": "9.3.4", "Orientation": 3, ..., "ExifVersion": "b'0221'", ..., "LensModel": "iPhone 6s front camera 2.65mm f/2.2" } }, { ..., "exif": { ..., "Make": "Apple", "Model": "iPhone 6s", "Orientation": 6, ..., "ExifVersion": "b'0221'", ..., "LensModel": "iPhone 6s back camera 4.15mm f/2.2" } }, {..., "exif": { ... "Make": "NIKON", "Model": "COOLPIX S33", "Orientation": 1, ..., "ExifVersion": "b'0230'", ... } }, {..., "exif": { ..., "Make": "SONY", "Model": "NEX-C3", "Orientation": 1, ... } }, ] |
画像の回転・反転
Exifに記録されている回転情報("Orientation"
タグ)を表す番号と方向はこんな感じの対応になっています。厳密には、単純な回転のみではなく、反転(鏡像)を含めたパターンが定義されています。
PillowではImage.transpose()
メソッドで修正することができるので、その引数(PIL.Image...
)を"Orientation"
タグに応じてセットし、正置方向に変換します。
長くなるので引数trans_
は定数で書いています。
1 2 3 4 5 6 | trans_ = [0, 3, 1, 5, 4, 6, 2][orientation_ - 2] # save modified image as new file with Image.open(item["path"]) as image_: # transpose to upright angle image_ = image_.transpose(trans_) image_.save(path_) |
Image.open()
メソッドで画像ファイルを開き、編集した後に同様にImage.save()
メソッドでExifを除いた画像本体を保存できます。Pillowには特定のExifタグのみを編集する機能はありません。
もともと正置の画像やExifが付いていない画像は処理しません。
サンプル
今回のサンプルスクリプトです。
実行時の引数に応じて、Exifの抽出・保存と修正の処理を実行します。
検証した環境はWindows 10 Pro, Python3.6, Pillow 5.1.0でした。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 | # -*- coding: utf-8 -*- import os import json import sys from traceback import print_exc from PIL import Image from PIL.ExifTags import TAGS def _exif(file_path): try: # simply format from bytes to str for json serialization def _format_bytes(obj_): res = {} for key_, value_ in obj_.items(): if isinstance(value_, bytes): res[key_] = "{}".format(value_) elif isinstance(value_, dict): res[key_] = _format_bytes(value_) else: res[key_] = value_ return res # read exif with Image.open(file_path) as f: exif_ = f._getexif() # convert to readable dict info_ = {} for key_ in exif_.keys(): tag_ = TAGS.get(key_, key_) # skip longer value if tag_ in ["MakerNote", "UserComment"]: continue info_[tag_] = exif_[key_] return _format_bytes(info_) except AttributeError: # it might not have exif attached ('exif_' is None) return {} except BaseException: raise def list_exif(dir_path): # generator for picking up jpeg files def _scan(): for root, _, files in os.walk(dir_path): for file_ in files: flags_ = [file_.lower().endswith(x) for x in [".jpeg", ".jpg", ".jpe"]] if sum(flags_) > 0: yield os.path.join(root, file_) else: continue # check path if not os.path.isdir(dir_path): raise Exception("invalid directory path") # build info for each jpeg file res = [] for item in _scan(): path_ = os.path.normpath(item) buf_ = {"path": path_, "file": os.path.basename(path_), "dir": os.path.dirname(os.path.relpath(path_, dir_path))} # add metadata buf_["exif"] = _exif(path_) res.append(buf_) return res def remove_exif(dir_path): # transpose and remove exif for each image files for item in [x for x in list_exif(dir_path) if x["exif"] != {}]: orientation_ = item["exif"]["Orientation"] # skip files if not need to be transposed if orientation_ < 2: continue # new file name with 'r' appended path_ = "{}-r{}".format(*os.path.splitext(item["path"])) print("save file: {} (orientation:{})...".format(path_, orientation_)) trans_ = [0, 3, 1, 5, 4, 6, 2][orientation_ - 2] # save modified image as new file with Image.open(item["path"]) as image_: # transpose to upright angle image_ = image_.transpose(trans_) image_.save(path_) if __name__ == "__main__": try: # specify directory path where JPEG image files are stored path_ = "path/to/dir" if len(sys.argv) == 2: if sys.argv[1] == 'rm': remove_exif(path_) else: res = list_exif(path_) # save as json file with open(os.path.join(path_, "exif.json"), 'w') as f: json.dump(res, f, indent=2, ensure_ascii=False) except BaseException: print_exc() |
おわり。