Pythonで写真のExifから焦点距離情報とレンズ情報を抽出した話

# 概要

**「自分はどのレンズをよく使ってるんだろう？」**
カメラ好きなら一度は思ったことでしょう。
写真1枚について調べたい場合は、RAW現像ソフトを使えばExifに記録された情報を確認できます。
しかし、写真フォルダーにある大量の写真となると、カウントするだけでも一苦労です。

……そこで、Python 3と[Pillow](https://note.nkmk.me/python-pillow-basic/)を使用し、情報を自動で収集するプログラムを書いてみました。以下、その際のポイントについて。

# Exif抽出だけなら難しくない

幸い、Exif情報を辞書形式で取り出すだけなら、Pillowを使うだけで簡単に書けます。
焦点距離についても、実焦点距離、および35mm判換算焦点距離を別々に取り出すことができます。

```python
from PIL import Image
from PIL.ExifTags import TAGS
from PIL.MpoImagePlugin import MpoImageFile

# 読み込みたい画像ファイルのパス
path = 'test.jpg'

# 読み込み
im: MpoImageFile = Image.open(path)

# Exif情報を取り出す
exif = im.getexif()

# tag_idはExif情報のキー、valueはExif情報の値。
# tag_idはstr型ではないので、TAGS.getメソッドによってstr型に変換する
for tag_id, value in exif.items():
  tag = TAGS.get(tag_id, tag_id)
  if tag == 'FocalLength':
    print(f'実焦点距離：{value}')
  if tag == 'FocalLengthIn35mmFilm':
    print(f'35mm判換算焦点距離：{value}')
```

また、レンズ名についての情報は、[MakerNote](https://en.wikipedia.org/wiki/Exif#MakerNote_data)と呼ばれる、各カメラメーカー毎に固有のフォーマットを持つバイナリ文字列で記録されています。
Exif情報のフォーマットと異なり書式は公開されていませんが、非公式な解析結果がググれば幾らでも出てきます。今回はパナ社のカメラで記録した写真についての情報のみ考えることにします。

```python
maker = ''
maker_note = b''
for tag_id, value in exif.items():
  tag = TAGS.get(tag_id, tag_id)
  if tag == 'MakerNote':
    maker_note = value
  if tag == 'Make':
    maker = value
if maker == 'Panasonic' and maker_note != b'':
  # 以下、解析処理
```

# MakerNoteとの格闘

[ググって出てきた情報](http://www.ozhiker.com/electronics/pjmt/jpeg_info/panasonic_mn.html)を見る限り、パナの場合、「固有のヘッダー文字列」＋「TIFF IFDっぽいバイナリ列」＋「その他バイナリデータ」で表現されます。
固有のヘッダー文字列は読み飛ばせばいいのですが、問題はTIFF IFD。こちらも[ググって出てきた情報](https://jprogramer.com/libtiffcate/3188)を読んだ感じ、次のような書式になっているようです。

- 12バイトの固定長なレコードがn個並んでおり、それらの先頭に「何個レコードが並んでいるか」を示す情報が2バイト分使用して記録されている
- バイナリはいずれもリトルエンディアン
- 各レコードについて、
  - 先頭2バイトが「レコードの種類」
  - 次の2バイトが「レコードに含まれる値の型」
  - 次の4バイトが「レコードに含まれる値の個数 or 値の全長(バイト単位)」
  - 次の4バイトが「レコードに含まれる値 or "先頭"からのオフセット(バイト単位)」

つまり、処理としては、「何個レコードが並んでいるか」を読み取り、その個数分だけIFDを読み取る……といった動きになります。厄介なのは、「IFDには実際のデータが収録されている」のか、「IFDには実際のデータへの参照が収録されている」のかを判別する必要があるということです。

---

例えば、あるIFDが16進数で「03 00 03 00 01 00 00 00 03 00 00 00」と記録されていた場合、

- 先頭「03 00」＝「0x0003」＝「ホワイトバランスの情報」
- 次の「03 00」＝「0x0003」＝「short型(2バイト整数)」
- 次の「01 00 00 00」＝「0x00000001」＝「データ数は1個(データ長からしても間違いない)」
- 次の「03 00 00 00」＝「0x00000003」＝「ホワイトバランス設定は3("曇り"プリセット)」

だと分かります。しかし、IFDが16進数で「51 00 02 00 22 00 00 00 64 0D 00 00」と記録されていた場合、

- 先頭「51 00」＝「0x0051」＝「レンズ名の情報」
- 次の「02 00」＝「0x0002」＝「char型(1バイト整数、ASCII文字列となる)」
- 次の「22 00 00 00」＝「0x0000002」＝「データ長は34文字(レコードに34個も値は入らないのでデータ長である)」
- 次の「64 0D 00 00」＝「0x00000D64」＝「0スタートで3428オフセット目にデータが存在する」


となります。さて問題、**「3428オフセット目」はどこからが基準でしょうか？**

---

……これに対応するには、Exifファイルフォーマットについて知る必要があります。今までExifと言ってきましたが、これは単に「JPEGファイルに引っ付いたメタ情報」と言うだけではなく、**メタ情報を含ませた上でJPEG(JFIF)規格に準拠したファイルフォーマット**を指します。これによると、Exifと一般に呼ばれるメタデータは、「`Exif\x00\x00`」というシグネチャーから始まるバイナリ列です。

で、前述した「オフセット」の基準は、**この「Exif」シグネチャーが終わった直後**です(試行錯誤して確認)。つまり、JPGファイルをバイナリで読み込んで「Exif」シグネチャーを検索し、その直後の位置を記憶しておく必要があります。

ただ、**大変残念なことに**、上記PillowによるExifデータ読み取り処理では「Exifデータが画像ファイル全体からどれぐらいのオフセット位置に存在するか」を読み取ってくれないので、コードが次のように煩雑になります。つらい。

```python
exif_signature = b'Exif\x00\x00'
with open(path, 'rb') as f:
  # バイナリデータと、必要なオフセット値を取得
  raw_data = f.read()
  raw_exif_base_index = raw_data.find(exif_signature) + len(exif_signature)

  # Exif情報を取得
  im: MpoImageFile = Image.open(path)
  exif = im.getexif()

  # 中略。maker_noteにMakerNoteのバイナリが収められているとする
  ifd_count = int.from_bytes(maker_note[12:14], byteorder='little')
  for x in range(0, ifd_count):  # IFDを順に読んでいく
    pointer = 12 + 2 + x * 12
    ifd_data = maker_note[pointer:pointer + 12]
    tag_name = ifd_data[0:2].hex()
    if tag_name == '5100':  # レンズ名についてのIFDならば
      # レンズ名をバイナリデータから抽出
      # (末尾に不要な\x00があるので取り除く)
      name_length = int.from_bytes(ifd_data[4:8], byteorder='little')
      name_offset = int.from_bytes(ifd_data[8:], byteorder='little')
      start_index = raw_exif_base_index + name_offset
      end_index = start_index + name_length
      lens_name = raw_data[start_index:end_index].decode('ascii').replace('\0', '')
      print(f'レンズ名：{lens_name}')
      break
```

# おまけ：抽出結果

上記のようにして抽出した上で、pandasを使い、頻度分析を実施しました。
これ自体はExcelも併用したやっつけ仕事ですが、Pythonはグラフ化も得意なので、頑張れば「分析結果をPDFに書き出す」ぐらいまで実装できるかもしれません。

<blockquote class="twitter-tweet" data-conversation="none" data-cards="hidden" data-partner="tweetdeck"><p lang="ja" dir="ltr">今まで使ってきたレンズの使用率、およびレンズごとによく使用した焦点距離(35mm換算)を可視化。こういう時はPythonが便利ですね。 <a href="https://t.co/MTp0IoDH64">pic.twitter.com/MTp0IoDH64</a></p>&mdash; Y​S​R​@​も​の​べ​の​プ​レ​イ​中 (@YSRKEN) <a href="https://twitter.com/YSRKEN/status/1279257289023582209?ref_src=twsrc%5Etfw">July 4, 2020</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# おまけ2：自動分析用コード

上記のコードをより洗練させて、実行して放置すればCSV形式で標準出力に書き出すやつを作りました。以下、ソースコードと出力例。

https://gist.github.com/YSRKEN/832267d8fb810817ec233bc7f63e54df

```bash
(前略)

写真枚数：4293

レンズ名,使用率(％),焦点距離毎の使用率(％)
OLYMPUS M.12-40mm F2.8,20.6,80mm(36.2) 24mm(29.0) 50mm(3.3) 42mm(2.9) 48mm(2.8) 64mm(1.9) 60mm(1.8) 68mm(1.7) 54mm(1.6) 30mm(1.5) 34mm(1.5) 58mm(1.4) 76mm(1.2) 40mm(1.2) 28mm(1.2) 36mm(1.2) 52mm(1.1) 62mm(1.0) 46mm(1.0) 38mm(0.9) 158mm(0.8) 72mm(0.7) 56mm(0.7) 44mm(0.6) 70mm(0.5) 82mm(0.5) 32mm(0.5) 63mm(0.3) 94mm(0.3) 26mm(0.2) 73mm(0.2) 47mm(0.1) 89mm(0.1) 
LUMIX G VARIO 14-140/F3.5-5.6 ,15.5,28mm(26.1) 280mm(19.5) 92mm(7.6) 80mm(4.0) 52mm(3.9) 30mm(3.4) 305mm(3.3) 68mm(3.0) 42mm(2.4) 110mm(2.2) 38mm(2.2) 74mm(1.9) 48mm(1.9) 122mm(1.6) 64mm(1.5) 204mm(1.3) 60mm(1.2) 162mm(1.0) 138mm(1.0) 146mm(1.0) 34mm(1.0) 130mm(0.9) 56mm(0.7) 176mm(0.7) 32mm(0.6) 132mm(0.4) 184mm(0.4) 154mm(0.4) 168mm(0.4) 41mm(0.3) 100mm(0.3) 150mm(0.3) 119mm(0.3) 159mm(0.3) 230mm(0.3) 218mm(0.3) 222mm(0.1) 284mm(0.1) 87mm(0.1) 211mm(0.1) 200mm(0.1) 191mm(0.1) 69mm(0.1) 257mm(0.1) 295mm(0.1) 260mm(0.1) 
LUMIX G VARIO 100-300/F4.0-5.6II,12.7,600mm(58.1) 200mm(15.5) 428mm(3.3) 300mm(3.1) 446mm(2.2) 1212mm(2.0) 272mm(1.8) 376mm(1.5) 216mm(1.1) 572mm(1.1) 254mm(1.1) 386mm(1.1) 480mm(1.1) 228mm(0.9) 366mm(0.7) 468mm(0.5) 240mm(0.5) 400mm(0.5) 324mm(0.5) 342mm(0.5) 394mm(0.5) 500mm(0.4) 528mm(0.4) 420mm(0.4) 492mm(0.4) 410mm(0.2) 436mm(0.2) 456mm(0.2) 
OLYMPUS M.12-100mm F4.0,9.9,200mm(31.8) 24mm(18.4) 100mm(11.1) 395mm(9.0) 70mm(7.5) 50mm(5.2) 108mm(2.1) 140mm(1.9) 36mm(1.4) 68mm(1.4) 122mm(1.2) 48mm(1.2) 62mm(0.9) 47mm(0.7) 58mm(0.7) 32mm(0.7) 76mm(0.7) 114mm(0.5) 82mm(0.5) 94mm(0.5) 88mm(0.5) 40mm(0.2) 279mm(0.2) 339mm(0.2) 132mm(0.2) 150mm(0.2) 184mm(0.2) 160mm(0.2) 46mm(0.2) 34mm(0.2) 
(後略)
```

# 参考資料

- [Panasonic Makernote Format Specification](http://www.ozhiker.com/electronics/pjmt/jpeg_info/panasonic_mn.html)
- [Panasonic MakerNote Tags - Metadata reference tables](https://www.exiv2.org/tags-panasonic.html)
- [TIFFのフォーマット（その１） | JProgramer](https://jprogramer.com/libtiffcate/3188)
- [けんしのページ - Exifファイルフォーマット -](http://www2.airnet.ne.jp/~kenshi/exif.html)