gexiv2によるexifの読み取り

説明すること

gexiv2を用いたexifデータの抽出

Rationalを使った加工方法

exifdataの変更と書き戻しについては、convertに任せる

gexiv2の導入

Debian10(buster)上の python3 (Python 3.7.3)です。したがってパッケージ名はDebianのものです。私はsynapticでgexiv2をキーワードに検索して入れましたが、aptでも入れられます。

次のパッケージを入れます。

# apt install gir1.2-gexiv2-0.10

これにより入るファイルは

/usr/lib/python2.7/dist-packages/gi/overrides/GExiv2.py
/usr/lib/python3/dist-packages/gi/overrides/GExiv2.py
/usr/lib/x86_64-linux-gnu/girepository-1.0/GExiv2-0.10.typelib
/usr/share/doc/gir1.2-gexiv2-0.10/changelog.Debian.gz
/usr/share/doc/gir1.2-gexiv2-0.10/changelog.gz
/usr/share/doc/gir1.2-gexiv2-0.10/copyright
/usr/share/lintian/overrides/gir1.2-gexiv2-0.10

となっていて、python3にも対応していると確認が取れます。

使用方法

import文の書き方が面倒です。

import gi
gi.require_version('GExiv2', '0.10')
from gi.repository import GExiv2

importしてしまえば使い方は簡単で、文字列のfilenameを引数にして

metadata = GExiv2.Metadata(filename)

これで、type(metadata)はgi.overrides.GExiv2.Metadataですが、実質はkey,valueとも文字列の辞書であるかのように扱うことができます。もっとも、辞書のようにmetadata.keys()は使えません。metadata.get_tags()とします。

for k in metadata.get_tags():
    v = metadata.get(k)
    t = type(v)
    print (k,t,v)

一覧の出力が出ます

Exif.GPSInfo.GPSVersionID <class 'str'> 2 3 0 0
Exif.Image.Artist <class 'str'> 
Exif.Image.Copyright <class 'str'> 
Exif.Image.DateTime <class 'str'> 2020:02:09 15:40:25
Exif.Image.ExifTag <class 'str'> 348
Exif.Image.GPSTag <class 'str'> 18828
Exif.Image.Make <class 'str'> NIKON CORPORATION
Exif.Image.Model <class 'str'> NIKON D500
Exif.Image.Orientation <class 'str'> 6
Exif.Image.ResolutionUnit <class 'str'> 2
......

これでタグの文字列を調べて、

(A) metadata.get('Exif.Photo.DateTimeOriginal','not data'))
(B) metadata.get('Exif.Photo.DateTimeOriginal'))
(C) metadata['Exif.Photo.DateTimeOriginal']

などとします。得られるvalueはすべて文字列です。(A)の'not data'は辞書になかったときに返す値です。これがおすすめ。

(B)は辞書にないときは None が返ります。

(C)は辞書にないときは Unknown tag のエラーになります。

1/125s f5.6 60mmにならないか

上記が基本の値の出し方ですが、不都合なものもあります。

取得メソッド strでの値 一般的な表示
.get('Exif.Photo.ExposureTime') 10/1250 1/125
.get('Exif.Photo.FNumber') 56/10 5.6
.get('Exif.Photo.FocalLength') 600/10 60

ExposureTimeは分子が1の分数か整数。少なくとも既約分数になっていてほしい。

FNumberは2, 2.8, 4, 5.6, 8,...など小数点以下1桁の小数で.0は省略する。

FocalLengthは整数で、.0はつけない。(スマートフォンなどでは小数があり得る)

この3つのタグにはstr以外の値が返る特別なメソッドがあります。

取得メソッド type(v) str(v)
.get_exposure_time() Fraction 1/125
.get_fnumber() float 5.6
.get_focal_length() float 60.0

少し一般的な表示に近づきますが、.0を省くという部分が煩わしくなります。

そこで、この特別なメソッドは使わず、他と同じメソッドを使って文字列の値を得、 その文字列からFractionに変更する方法で不都合を回避します。

文字列からFractionを使って整形

Fractionは分数を分数として扱う変数の型です。'56/10', '11', '0.3' など文字列で表現された分数や整数、小数を引数にして値を定義できます(floatやintつまり、56/10や0.3など文字列でない値も入れられますが二進で計算されるため意外な結果になります)。

ExposureTime
Fractionの出力は既約分数になります。分母が1ならば分子の値になります。分子が1とは限りませんが、99%は大丈夫だと思いますのでこのままstr()して使うことにします。
from fractions import Fraction
tmp = Fraction('10/1250')
print(str(tmp))
# 1/125
tmp = Fraction('3')
print(str(tmp))
# 3
FNumber
小数点以下1桁の数字にしますが、整数にできるときは.0をつけません。tmp.numeratorとtmp.denominatorで、それぞれ分子・分母の値を得ることができるので、分母が1なら分子の値、そうでなければ割り算をして小数点以下1桁に丸めます。最近接偶数への丸めの様です。
tmp = Fraction('56/10')
if tmp.denominator == 1:
    sfnum = 'f{} '.format(tmp.numerator)
else:
    sfnum = 'f{:.1f} '.format(float(tmp.numerator/tmp.denominator))
# f5.6
FocalLength
FNumberと同様ですが、ほとんどの場合整数でしょう。
tmp = Fraction('600/10')
if tmp.denominator == 1:
    sflen = '({}mm) '.format(tmp.numerator)
else:
    sflen = '({:.1f}mm) '.format(float(tmp.numerator/tmp.denominator))
# 60mm

レンズ情報

レンズ交換式カメラの場合に、取り付けたレンズがわかると嬉しい。Olympusの場合は、MakerNoteではなく、Exif.Photo.LensModel で 'OLYMPUS M.14-42mm F3.5-5.6 EZ' のように得られます。Nikonでは、MakerNoteで知るしかありません。

Olympusの場合は、他社のレンズを使うことも想定してMakerNoteでないところに置いているようです。値にOLYMPUSが入っているのもそのためでしょう。MakerNoteにも値があります。

このタグは、辞書にキーが含まれないということが頻繁におこるでしょうから.get('Exif.GPSInfo.GPSLatitude','')などとしてデフォルト値を定めて対処するのが良いでしょう。

取得メソッド strでの値 一般的な表示
.get('Exif.Photo.LensModel') OLYMPUS M.14-42mm F3.5-5.6 EZ 同左
.get('Exif.Photo.LensSpecification') 14/1 42/1 35/10 56/10 14-42mm f3.5-5.6
.get('Exif.OlympusEq.LensModel') OLYMPUS M.14-42mm F3.5-5.6 EZ 同左
.get('Exif.Nikon3.Lens') 160/10 850/10 35/10 56/10 16-85mm f3.5-5.6

'Exif.Nikon3.Lens'はgexiv2では機種が違っても(つまりMakerNoteのバージョンが違っても)同じ形で返されました。すべてのNikon、将来のNikonでも保証されるかは不明ですが、今の所は便利。exifreadは現在でも違うkeyになります。

"160/10 850/10 35/10 56/10".split()で文字列を4つに分けて、それぞれをFractionを使って整形していけます。

プログラムの一部を示します。

metadata = GExiv2.Metadata(filename)
#...略...
    lensinfo = ''  #retv
    strv = metadata.get('Exif.Photo.LensModel','')
    if strv != '':
        lensinfo += strv
   
    if lensinfo == '':
        strv = metadata.get('Exif.Nikon3.Lens','')
        if strv != '':
            rlist = strv.split() #space+
            if len(rlist) == 4:
                flens = Fraction(rlist[0])
                if flens.denominator == 1:
                    lensinfo += '{}'.format(flens.numerator)
                else:
                    lensinfo += '{:.1}'.format(float(flens.numerator/flens.denominator))
                if rlist[0] != rlist[1]:
                    flens = Fraction(rlist[1])
                    if flens.denominator == 1:
                        lensinfo += '-{}mm '.format(flens.numerator)
                    else:
                        fltv = float(flens.numerator/flens.denominator)
                        lensinfo += '-{:.1f}mm '.format(fltv)
                else:
                    lensinfo += 'mm '
                flens = Fraction(rlist[2])
                #lensinfo += '({},{},{}) '.format(rlist[2],rlist[3],rlist[3].den)
                if flens.denominator == 1:
                    lensinfo += 'f{}'.format(flens.numerator)
                else:
                    fltv = float(flens.numerator/flens.denominator)
                    lensinfo += 'f{:.1f}'.format(fltv)
                if rlist[2] != rlist[3]:
                    flens = Fraction(rlist[3])
                    if flens.denominator == 1 or flens.denominator == 0 :
                        lensinfo += '-{}'.format(flens.numerator)
                    else:
                        #print('flens',flens)
                        fltv = float(flens.numerator/flens.denominator)
                        lensinfo += '-{:.1f}'.format(fltv)
            else:
                for r in rlist:
                    flens = Fraction(r)
                    if flens.denominator == 1:
                        lensinfo += '{} '.format(flens.numerator)
                    else:
                        fltv = float(flens.numerator/flens.denominator)
                        lensinfo += '{:.1f} '.format(fltv)

GPSデータ

たぶん、使うとすれば次の4つでしょうか。

取得メソッド strでの値 一般的な表示
.get('Exif.GPSInfo.GPSLatitude') 43/1 8/1 16307373/1000000 43°8′16.3″
.get('Exif.GPSInfo.GPSLatitudeRef') N 同左
.get('Exif.GPSInfo.GPSLongitude') 141/1 1/1 43216552/1000000 141°1′43.2″
.get('Exif.GPSInfo.GPSLongitudeRef') E 同左

これも"43/1 8/1 16307373/1000000".split()で3つに分けて、それぞれをFractionを使って整形するのがよいでしょう。それぞれ度分秒なので、60で割って加えて43.14度などと丸めてしまうのもありです。

プログラムの一部を示します。

metadata = GExiv2.Metadata(filename)
#...略...
    gpsinfo = ''  #retv
    gpsinfo += metadata.get('Exif.GPSInfo.GPSLatitudeRef','')
    strv = metadata.get('Exif.GPSInfo.GPSLatitude','')
    if strv != '': gpsinfo += gps_str2dms(strv)
    gpsinfo += metadata.get('Exif.GPSInfo.GPSLongitudeRef','')
    strv = metadata.get('Exif.GPSInfo.GPSLongitude','')
    if strv != '': gpsinfo += gps_str2dms(strv)

#...略...
def gps_str2dms(strv):
    retv = ''
    rlist = strv.split() #space+
    if len(rlist) == 3:
        d = Fraction(rlist[0])
        if d.denominator == 1:
            retv += '{}°'.format(d.numerator)
        else:
            retv += '{:.1f}°'.format(float(d.numerator/d.denominator))
        m = Fraction(rlist[1])
        if m.denominator == 1:
            retv += '{}′'.format(m.numerator)
        else:
            retv += '{:.1f}′'.format(float(m.numerator/m.denominator))
        s = Fraction(rlist[2])
        if s.denominator == 1:
            retv += '{}″ '.format(s.numerator)
        else:
            retv += '{:.1f}″ '.format(float(s.numerator/s.denominator))
    else:
        for dms in rlist:
            d = Fraction(dms)
            if d.denominator == 1:
                retv += '{} '.format(d.numerator)
            else:
                retv += '{:.1f} '.format(float(d.numerator/d.denominator))
    return retv

convertに任せる

たくさんのデジタル写真からサムネイル付きのhtmlページをexifデータの注釈とともに作っています。以前からrubyでこのプログラム書いていましたが今回pythonで作り直しました。

rubyでもexifはいくつかライブラリがあってLinuxディストリビューションがバージョンを上げるとexif部分が使えなくなるということがありました。python3ならもう少し整備されていると期待していましたが、やはり乱立と言える状態に感じます。

rubyでは写真のOrientationによる回転とサムネイルづくりの縮小はImageMagickのconvertコマンドを呼び出してやっていました。

pythonではPILがあって、pythonだけで処理が完結するように書けそうだと考えて調べていたのですが、やはりconvertに任せることにしました。

(1)回転済みの画像はOrientationの値を1に戻して置かなければなりませんが、exifreadでMakerNoteが読めなくなる不具合が見つかったりして、書き込み部分が怪しいのです。convertではOrientationの面倒も見てくれます。

(2)縮小の指定がPILでは画像が横長か縦長かによって異なる指示をしなければなりません。convertでは800x800と支持すれば縦長は縦が800、横長は横が800に縮小されます。これは便利です。

python3からconvertを実行するプログラム

ここでも、プログラムの一部を示します。

python3からコマンドを呼び出す方法はいくつかあります。Linuxで作成していますが、Windowsでもこのまま動くと思います。ただし、linuxもWindowsもImageMagickがインストールされている前提です。

Windowsとの差を吸収するためにpathlibを使ってみていますが、Windowsでは試していませんので、だめならば素直にパス名+ファイル名を初めから文字列で書いてください。

pathlibとファイル名との連結は / を文字列連結の + の様に使います。これは便利。

import subprocess
import pathlib

#...略...
sdir = pathlib.Path('imgorg')
ddir = pathlib.Path('imgsm')
fname = 'p001.jpg'

strlist = [ 'convert', str(sdir / fname) ]
strlist.extend( '-auto-orient -resize 960x960'.split() )
strlist.extend( '-quality 95'.split() )
strlist.append( str(ddir / fname) )
subprocess.run(strlist)

subprocess.run()の引数として、コマンドラインに書く一連の文字列を書いてshellに解釈させる方法もありますが、ここで採用したのは文字列のlistを書く方法です。それぞれの項目をプログラムで用意する場合にも親和性がありますし、行を分けるのも楽です。

スペースで分かち書きした文字列から.split()でlistを作って、.expand()でlist同士を結合しています。一つの文字列をlistに加えるのに.append()を使っています。