公開日 2024-03-05 更新日 -
スマートフォンから取り出した写真にExifの撮影日時データがないものがたんさんありました。1608975591739.jpgなどのファイル名から日時を取り出せます。なんとか撮影日時を(無理なら保存日時を)Exifに書き込んで写真の整理に役立てようというお話です。
Debian11上のpython3を端末上で使っています。Pythonのバージョンは3.9.2です。Debian12の環境でもほとんど変わりません。
pythonの使用例として、bashの変数展開した引数の使用、datatimeオブジェクト、ファイルのタイムスタンプの利用、Exifデータの読み書き、正規表現、pathlibによるファイルの読み込み、argparseによる引数の管理 などに触れています。
動画については、次回。
デジタルカメラの写真をPCに移すと、日付がずれるという現象に最近気がつきました。
使用しているOSはLinuxです。Linuxでは時刻は協定世界時(UTC)で管理して、ファイルのタイムスタンプもUTCになっています。タイムゾーンをASIA/TOKYOにすることで私達の目に触れるタイムスタンプは+9時間されたもの、つまり日本標準時(JST)になります。
写真に示した例では、Exifに記録された撮影時刻は7月30日15時58分31秒で、SDカードのファイルシステム内のタイムスタンプもこの時刻だと思われます。これをカメラからタイムスタンプを変更せずにコピーまたは移動すると、Linuxの中ではUTCとして記録されます(もちろんExifは常にそのままになります)。ファイルマネージャで表示すると9時間進められて、7月31日00時58分31秒となってしまうというのが原因なのでしょう。
SDカードのファイル管理にはFAT32やexFATが使われています。exFATのファイルシステムにはタイムゾーンの情報を入れる場所が追加されていて、64GB以上のSDカードが使えればexFATになっているので解決するという話も聞きましたが、私の環境ではうまくいきませんでした。カメラが対応していないのだと思います。
Exifにもタイムゾーンというタグが追加されているようです。まだ使われていないと思っています。
タイムゾーンへの対応がバラバラな現状ではWindowsの方針が無難ですが、海外で撮影した写真や、海外にノートPCを持っていったときに保存した日付がどうなっていてほしいかは、条件によりますし、人にもよります。時刻にはタイムゾーンの情報を入れて、表示したいタイムゾーンを後で選択しても、ちゃんと換算してくれるようにするのが最終形態のような気がします。
しばらく無理のようなので、Exif情報を読み出して、ファイルのタイムスタンプを書き換えるというプログラムをPyhton3で書いて対処しました。
スマートフォンのストレージが逼迫してPCにバックアップしました。「Google Photos」に自動でバックアップされているものもありますが、LINEで送受した写真や動画は含まれません。そこで、USBで直接接続してコピーをしました。
Androidですが、USB接続をしてファイル転送を許可すると、ファイルマネージャにUSBメモリをつないだかのようにアクセスができます。
タイムスタンプを保持してコピーするために、ここで端末を開いて cp -a を使います。
端末では、ファイルシステム内で特別な扱いを受けています。通常のUSBメモリならば、pwdで /media/adachi/68BD-5709 などと表示されます。
debian11のGNOMEではプロンプトのデフォルトがカレントディレクトリの表示なので pwd は無駄な作業です。
adachi@banach:/run/user/1000/gvfs/mtp:host=SHARP_Corporation_SHV45_357981105055115$ pwd /run/user/1000/gvfs/mtp:host=SHARP_Corporation_SHV45_357981105055115 adachi@banach:/run/user/1000/gvfs/mtp:host=SHARP_Corporation_SHV45_357981105055115$ ls -l 合計 0 drwx------ 1 adachi adachi 0 1月 1 1970 内部共有ストレージ
ですが、通常のコマンドが使えます。
adachi@banach:/run/user/1000/gvfs/mtp:host=SHARP_Corporation_SHV45_357981105055115$ cd 内部共有ストレージ/ adachi@banach:/run/user/1000/gvfs/mtp:host=SHARP_Corporation_SHV45_357981105055115/内部共有ストレージ$ ls -l 合計 0 drwx------ 1 adachi adachi 0 8月 25 2020 Alarms drwx------ 1 adachi adachi 0 12月 11 2020 Android drwx------ 1 adachi adachi 0 7月 11 2021 Audiobooks drwx------ 1 adachi adachi 0 5月 3 2021 DCIM drwx------ 1 adachi adachi 0 10月 22 04:42 Download drwx------ 1 adachi adachi 0 2月 20 2021 Fonts drwx------ 1 adachi adachi 0 11月 9 2021 Movies drwx------ 1 adachi adachi 0 11月 27 23:23 Music drwx------ 1 adachi adachi 0 8月 25 2020 Notifications drwx------ 1 adachi adachi 0 2月 28 14:18 Pictures drwx------ 1 adachi adachi 0 8月 25 2020 Podcasts drwx------ 1 adachi adachi 0 7月 6 2022 Recordings drwx------ 1 adachi adachi 0 8月 25 2020 Ringtones drwx------ 1 adachi adachi 0 12月 19 2020 data drwx------ 1 adachi adachi 0 7月 27 2022 documents
画像や動画が含まれるのは、DCIM, Movies, Pictures です。
$ tree -d DCIM DCIM ├─ 100SHARP ├─ 101INCAM ├─ SharpShootedPics │ └── BURST001 └─ mei └── temp $ tree -d Movies Movies └─ LINE $ tree -d Pictures Pictures └─ LINE
DCIMの中にあるのは標準で入っているカメラアプリで撮影されたもので、Exifデータがあります。そして、タイムスタンプもそれに合致しています。つまり上に書いた+9時間の問題がありません。普通のLinuxのマウントでないのでその仕組みは不明です。
MoviesとPicturesの動画と写真はLINEフォルダの外にも転がっていて、LINEアプリで撮影したものやLINEその他で受け取ったものなどが混在しているようです。今の所分類の仕組みが見えていません。
DCIMには若干の動画と写真, Moviesは動画, Picturesには写真です。
Picturesにある写真の多くは 1608975591739.jpg などという名前で、Exifのデータがありません。ファイル名の1608975591739の数値がUnix時間に見えたので換算してみると2020年12月26日18時39分51.739秒にあたります。unix時間は協定世界時(UTC)で1970年1月1日午前0時0分0秒からの秒数(またはミリ秒、またはマイクロ秒)で表された時刻です。ここではミリ秒でした。ただし、UTCではなくJSTでのミリ秒のようです。JSTなのは将来的にはまずいかもしれません。
この他に、line_1565966289093.jpg のようにアレンジされたもの、IMG_20201211_132124.jpg のように普通の日時が使われたもの、そして、謎のline_305390974919230.jpg のようにunix時間とすると計算が合わないものもあります。そもそも普通13桁なのに15桁あります。いろいろな機種から送信されたものがありますし、時刻も相手側がつけたものか、受信側の保存時間かも不明ですが、とりあえず日時の情報を取り出して、撮影時刻に最も近いものを確定して整列や分類をしたいということで、プログラムをつくることにします。
Exifではすでにpythonでのプログラムがありますから、unix時間の部分を調べて追加するという方針で始めます。
はじめはファイル名を 20201211_132124 のようなものに統一することを考えましたが、時刻がたまたま一致することを考えたり、もとの名前からわかる素性みたいなものを残すことを考えると、ファイル名はそのままにして、Exif情報を書き込むほうが優れている気がしてきました。
SNSなどで流れた写真にGPSデータが残っていると撮影者の素性が特定されるという懸念から、送信時にはこれを自動で消去するというのが一般的になるのは良いとしても、撮影時刻まで取り去るのはどうかと思うのですが、この延長でUnix時間もわからないようになっていくのかもしれませんね。
採用する日付: 以下のタイムスタンプ、Exif、unix時間などから最終的に採用した日時です。最後の3桁はミリ秒です。値がないものも統一して表示するようにしています。その場合は-000となります。
file-timestamp: タイムスタンプのうち最終更新日時です。LINUXでは作成日時の記録がありません。
exif-date: Exifの DateTimeOriginal です。データのないものは - になっています。
unixtime-fname: 今回注目しているファイル名に記録されているunix時間から求めた時刻です。15桁あるものは後半の13桁を換算しています。
86(66)-filename: 20201123_101112.jpgのように普通の日時がファイル名に含まれているものです。今回はすべてExifが含まれていたのでこれを採用したものはありません。
採用元: どれを採用したかの記録です。
Exifがあるものについてはこれを優先しました。次にファイル名のUnix時間です。その次が86(66)で、最後にタイムスタンプです。タイムスタンプはExifに近いものもあり、まとめて保存したような時刻になっているものもあります。LINEなどで受けとった写真は送信時間になったり、保存時間になったりなのでしょう。
file name | 採用する日付 | file-timestamp | exif-date | unixtime-fname | 86(66)-filename | 採用元 |
---|---|---|---|---|---|---|
DSC_0221.JPG | 20180217_111418-000 | 20200825_021818-000 | 20180217_111418-000 | - | - | exif |
IMG_0953.JPG | 20171107_090100-000 | 20200825_021816-000 | 20171107_090100-000 | - | - | exif |
DSC_2823.JPG | 20230821_132851-000 | 20230821_222852-190 | 20230821_132851-000 | - | - | exif |
IMG_20201123_101112.jpg | 20201123_101113-000 | 20200825_022720-000 | 20201123_101113-000 | - | 20201123_101112-000 | exif |
IMG_20170316_153212.jpg | 20170316_153213-000 | 20201211_163656-000 | 20170316_153213-000 | - | 20170316_153212-000 | exif |
1513175873396.jpg | 20171213_233753-396 | 20200825_021838-000 | - | 20171213_233753-396 | - | unixtime-from-fname |
1588653014093.jpg | 20200505_133014-093 | 20200825_022044-000 | - | 20200505_133014-093 | - | unixtime-from-fname |
1619996477265.jpg | 20210503_080117-265 | 20210503_080116-000 | - | 20210503_080117-265 | - | unixtime-from-fname |
1607847545223.jpg | 20201213_171905-223 | 20201213_171905-000 | - | 20201213_171905-223 | - | unixtime-from-fname |
1633006447433.jpg | 20210930_215407-433 | 20210930_215407-000 | - | 20210930_215407-433 | - | unixtime-from-fname |
1618913852784..jpg | 20210420_191732-784 | 20210420_191732-000 | - | 20210420_191732-784 | - | unixtime-from-fname |
1633260266356.jpg | 20211003_202426-356 | 20211003_202426-000 | - | 20211003_202426-356 | - | unixtime-from-fname |
1707553778311.jpg | 20240210_172938-311 | 20240210_172938-000 | - | 20240210_172938-311 | - | unixtime-from-fname |
line_1525249363634.jpg | 20180502_172243-634 | 20200825_022720-000 | - | 20180502_172243-634 | - | unixtime-from-fname |
line_1575636357022.jpg | 20191206_214557-022 | 20200825_022722-000 | - | 20191206_214557-022 | - | unixtime-from-fname |
line_1578120547741.jpg | 20200104_154907-741 | 20200825_022740-000 | - | 20200104_154907-741 | - | unixtime-from-fname |
line_1565966289093.jpg | 20190816_233809-093 | 20201211_163819-000 | - | 20190816_233809-093 | - | unixtime-from-fname |
line_1573206174253.jpg | 20191108_184254-253 | 20201211_163830-000 | - | 20191108_184254-253 | - | unixtime-from-fname |
line_1579855693867.jpg | 20200124_174813-867 | 20201211_163900-000 | - | 20200124_174813-867 | - | unixtime-from-fname |
CameraIntent_1651908399343.jpg | 20220507_162639-343 | 20220507_162753-000 | - | 20220507_162639-343 | - | unixtime-from-fname |
20181004医務室.jpg | 20200825_021822-000 | 20200825_021822-000 | - | - | - | timestamp |
DSC_0149.JPG | 20200825_021814-000 | 20200825_021814-000 | - | - | - | timestamp |
IMG_0159.jpg | 20200825_021824-000 | 20200825_021824-000 | - | - | - | timestamp |
line_297274030291617.jpg | 20200825_022128-000 | 20200825_022128-000 | - | 22000704_125811-617 | - | timestamp |
line_659525353188424.jpg | 20200825_021906-000 | 20200825_021906-000 | - | 22711106_122628-424 | - | timestamp |
ここからは、まず使用する部品ごとに説明して、使用例を示します。
一つのファイルで試したあと、フォルダ内の全部のファイルの処理をするなどの場合に、これを使うと便利です。
つまり、例えば、pythonのプログラムファイル名が try00.py だとして、
$ python3 try00.py ./test/sample.jpg
とか、
$ python3 try00.py ./test/*
という具合です。
このためには、
import sys for x in sys.argv[1:]: print(x)
argv は引数ですが、typeは list です。[1:]は2番目の要素から最後までということです。[0]にはプログラムファイル名(ここでは try00.py)が入っています。リストから要素を一つづつ取り出して 変数x に代入し、それを表示しています。
実行例はたとえばこんな感じです。
$ python3 ./try00.py test/* test/1513175873396.jpg test/1588653014093.jpg test/1619996477265.jpg test/20181004医務室.jpg test/DSC_0149.JPG test/DSC_0221.JPG
print文はすべて f"mmmm{v1}nnnn{v2}" の形式に統一してあります。これは、mmmm,nnnnの部分はそのまま表示し、{v1},{v2}の部分に{}内の変数や計算式の値を表示するというすぐれものです。実行結果の説明のために、(a),(b)などを行頭に書いています。
import sys import os import datetime for filename in sys.argv[1:]: print(f"(a)filename={filename}") nfmt = '%Y/%m/%d %H:%M:%S-%f' ostat = (os.stat(filename)) print (f"(b)os_stat's type={type(ostat)}") #<class 'os.stat_result'> print (f"(c)os_stat={ostat}") fmtime = ostat.st_mtime #float値(Unix時刻) fctime = ostat.st_ctime print (f"(d)m-time={fmtime}, c-time={fctime}") fmobj = datetime.datetime.fromtimestamp(fmtime) #datetimeオブジェクト fcobj = datetime.datetime.fromtimestamp(fctime) print (f"(e)m-tzinfo={fmobj.tzinfo},c-tzinfo={fcobj.tzinfo}") fmdate = fmobj.strftime(nfmt) #日時文字列 fcdate = fcobj.strftime(nfmt) print (f"(f)m-date={fmdate}, c-date={fcdate}")
実行例を示しながら、説明を加えます。try01.pyはプログラムファイル名で、ファイルを一つだけ引数にしています。
$ python3 ./try01.py test/1513175873396.jpg (a)filename=test/1513175873396.jpg (b)os_stat's type=<class 'os.stat_result'> (c)os_stat=os.stat_result(st_mode=33188, st_ino=49471, st_dev=2065, st_nlink=1, st_uid=1000, st_gid=1000, st_size=88782, st_atime=1709164800, st_mtime=1598289518, st_ctime=1709129331) (d)m-time=1598289518.0, c-time=1709129331.49 (e)m-tzinfo=None,c-tzinfo=None (f)m-date=2020/08/25 02:18:38-000000, c-date=2024/02/28 23:08:51-490000
(a): try00.py で x であったものが、filename になっています。
(b): os.stat(filename)の結果を ostat という変数に格納しました。変数のタイプは 'os.stat_result' というclassで辞書のような構造になっています。
(c): ここでは os.stat(filename) の持っているデータ全部を吐き出させています。時刻関係は st_atime, st_mtime, st_ctime の3つで、最近話題となっている st_birthtime は入っていないことがわかります。
(d): 時刻は ostat.st_mtime などで ostat から取り出します。これは float値(小数部のある数) です。〜time という変数は floatという命名ルールにしました。この値はunix時間と言われるものです。UTCのはずです。
(e): eの直前で fmtime などから fmobj を作っています。これは datetimeオブジェクトで、これからいろいろなメソッドを使ってタイムゾーンを指定したりして年月日、時分秒を引き出します。datetimeオブジェクトにはタイムゾーンを含んでいる場合と含まない場合があります。(e)ではtzinfoで確認しています。Noneとなればタイムゾーンを含んでいないという意味です。
(f): fの直前で datetimeオブジェクトから、strftime()メソッドで時刻を表す文字列を nfmt変数で指示した形式で取り出しています。タイムゾーンの指示がないのでシステムのタイムゾーンを使って 'Asis/Tokyo' つまりJSTに換算しています。%Yが4桁の西暦、%mが2桁の月などと決まっています。%fはミリ秒の部分です。6桁ですから、マイクロ秒となっていますが、実際にはその精度はありません(OS依存です)。
変数は 〜time が float, 〜objがdatetimeオブジェクト, 〜dateが文字列 というルールにしています。決めておかないと混乱します。
タイムスタンプは、
atime: 最終アクセス日時 mtime: 最終内容更新日時 ctime: メタデータ(上記os.stat()の内容)の最終更新日時(UNIX)
ctimeは Windows では作成日時になっていますが Linuxでは違います。上記の結果を見ると ctimeの方が新しくなっていることでわかります。ファイルのコピーや移動でinode(statデータの記録場所)が作成されて所有者番号なども書き込まれまし。その日時がctimeです。作成日時だと、mtimeの方が常に新しくなるはずです。
Linuxでも作成日時をということで st_birthtime が追加されるという話もありますが、まだのようです。今回の目的にはctimeよりもmtimeを使っていくのが吉です。
ここでは、GExiv2を使います。debianではpython3のパッケージ群としてlibgexiv2-2がすでに入っていますが、gir1.2-gexiv2-0.10 (0.10.9-1) を追加する必要があります。
import sys import os import datetime import gi gi.require_version('GExiv2', '0.10') from gi.repository import GExiv2 for filename in sys.argv[1:]: print(f"(a)filename={filename}") exfmt = "%Y:%m:%d %H:%M:%S" nrfmt = '%Y/%m/%d %H:%M:%S' xiv = GExiv2.Metadata(filename) key = "Exif.Photo.DateTimeOriginal" val = xiv.get(key,'') print(f"(b)Exifvalue={val}") if val != '': eobj = datetime.datetime.strptime(val,exfmt) else: eobj = datetime.datetime.min #eobj = datetime.datetime.utcfromtimestamp(0) edate = eobj.strftime(nrfmt) print(f"(c)edate={edate}")
importの部分で gi が含まれる3行はExif関係のものです。デジタルカメラの写真からカメラの機種名、露出、シャッター速度などを取り出す時に一番便利だったものです。
GExiv2.Metadata(filename)で、辞書のようなオブジェクトを得て、.get(key)で値を取得します。.get(key,'')などの2つ目の引数で、値がなかった時に代わりに返す値を指定できます。この仕組みはかなり便利。
Exifで日付が取れるときの実行結果の一例は、
$ python3 ~/python3/try02.py test/IMG_0953.JPG (a)filename=test/IMG_0953.JPG (b)Exifvalue=2017:11:07 09:01:00 (c)edate=2017/11/07 09:01:00
(b) Exifの日付は年月日の区切りもコロンなのがわかります。
そこでexfmtでその形式を示して、datetime.datetime.strptime(val,exfmt)により、Datatimeオブジェクトを作ります。
(c) Datatimeオブジェクトから.strftime(nrfmt)で、nrfmtで指定した形式で日時の文字列を得ることができます。
Exifで日付が取れないときは、
$ python3 ~/python3/try02.py test/1513175873396.jpg (a)filename=test/1513175873396.jpg (b)Exifvalue= (c)edate=1/01/01 00:00:00
(c) else: 部分でdatetime.datetime.minにより、一番昔の日付のオブジェクトを作成しています。これは、"1/01/01 00:00:00" にあたります。
以前はdatetime.datetime.utcfromtimestamp(0)でunix時間の 0 を入れ、"1970/01/01 00:00:00" を得ていたのですが、unix時間を調べて、負でも良いことに気が付き、minにしてみました。unix時間は32ビット、64ビット、整数、floatといろいろな表し方があるので、"1/01/01 00:00:00"が一番昔と決まっているわけではないのですが、プログラム内で使用する分には問題ありません。
タイムスタンプ、Exifの時刻、ファイル名のUnix時間といろいろな時刻はフォーマットがまちまちになるので、Datatimeオブジェクトに統一したのですが、値がない場合を表すために、試行錯誤しています。
Exifにはタイムゾーンやミリ秒などを表すキーもあります。
タイムゾーンは、
key = "Exif.Image.TimeZoneOffset" #A 16-bit (2-byte) signed integer print(f"{key}={xiv.get(key,'None')}")
この値は2バイト整数ですから、±12時間程度の数値、または ±720分程度、または ±43200秒と考えていくと秒単位は無理ですね。
OffsetTimeで調べると、OffsetTime, OffsetTimeOriginal, OffsetTimeDigitized とどこかで見たセットもあります。
key = "Exif.Photo.OffsetTimeOriginal" #Ascii print(f"{key}={xiv.get(key,'None')}")
この値はASCIIですから、"ASIA/TOKYO"かもしれませんし、'+9:00'かもしれません。どちらにしても、タイムゾーン関係の値のある写真に出会ったことがありません。
ミリ秒、マイクロ秒などの秒以下の値もあるようです。これは"23"とか"579"とかの文字列が出てくるものがあります。
datetimeオブジェクトの生成時に追加して使えますが、今回は面倒なので使いません。
key = "Exif.Photo.SubSecTimeOriginal" #Ascii print(f"{key}={xiv.get(key,'None')}")
これからやろうとしているのはこんなファイル名です。
1513175873396.jpg line_1525249363634.jpg CameraIntent_1651908399343.jpg
タイムスタンプのところで出てきたUnix時刻のfloat値、(d)m-time=1598289518.0, c-time=1709129331.49 によく似ています。桁数が13桁なので、単位がミリ秒と考えれば良さそうです。単位が秒だと連射などで同じになってしまう可能性があります。ファイル名は同じものがあってはいけませんから、ミリ秒にしてそれを回避しているのでしょう。
どれも、数字が13桁のあとに.jpgですから、正規表現を使って13桁の数値を取り出すことを考えます。IMG_20201123_101112.jpg みたいなものは排除する必要があります。
pythonで正規表現関係のメソッドはいろいろですが、ざっと眺めて何をやっているかわかった気になれて将来の応用もできそうなものとして、findall()に注目しました。
#----以下は実行結果です。
import re filename = "line_1525249363634.jpg" results = re.findall('([0-9]{13})\.jpg',filename) print(f"results's type ={type(results)}, its length={len(results)}") print(f"results ={results}") #---- #results's type =<class 'list'>, its length=1 #results =['1525249363634']
正規表現はプログラムによりいろいろクセが強いのですが、0123456789の間のどれかが[0-9]、13個つながっていて{13}、この部分を取り出して使うので()で括っておいて、その後ろが.jpgであるものを探す、というのはまあ、わかります。ちなみに .jpg だと . が任意1文字なので 2jpg でも ajpg でも合致してしまいますから、\ をつけます。
今の所、扱うファイルの中に .jpg の前に数字以外のものが入るものはありません。
また、15桁のものはありますが、上位13桁を取り出しても、単位をマイクロ秒としてもうまく行きませんので無視します。15桁は上の'([0-9]{13})\.jpg'では下位13桁が取り出されますが、変換された年月日で判断します。
.findall() のallは見つかったものをリストにして返すようです。2つ入っていることはないので無駄なようですが、results[0]で最初の文字列を取り出せますから、これで行きます。
ちなみに、複数ある場合を見てみます。13桁ではなく3桁であることに注意。
import re filename = "abc123.jpg34456.JPGdefg999789.jpg" results = re.findall('([0-9]{3})\.jpg',filename) print(f"results's type ={type(results)}, its length={len(results)}") print(f"results ={results}") #---- #results's type =<class 'list'>, its length=2 #results =['123', '789']
ファイルの拡張子は.JPGや.jpegかもしれません。
(jpg|JPG|jpeg) とすればいいのですが、()で括ったためにこの内容も結果に盛り込まれます。
文字列のリストだったものが、文字列のタプルのリストになります。リストの中のタプルも展開してみます。
import re filename = "abc123.jpg34456.JPGdefg999789.jpg" results = re.findall('([0-9]{3})\.(jpg|JPG|jpeg)',filename) print(f"results's type ={type(results)}, its length={len(results)}") print(f"results ={results}") for kouho in results: print(f"kouho's type ={type(kouho)}, its length={len(kouho)}") print(f"kouho ={kouho}") #---- #results's type =<class 'list'>, its length=3 #results =[('123', 'jpg'), ('456', 'JPG'), ('789', 'jpg')] #kouho's type =<class 'tuple'>, its length=2 #kouho =('123', 'jpg') #kouho's type =<class 'tuple'>, its length=2 #kouho =('456', 'JPG') #kouho's type =<class 'tuple'>, its length=2 #kouho =('789', 'jpg')
実際のファイル名に使うときにはリストとしては要素が一つで、そのタプルの中の1つ目の要素を取り出せば良いので、results[0][0]で'123'を取り出せることになります。
(3-α)を踏まえて、正規表現は '([0-9]{13})\.+(jpg|JPG|jpeg|JPEG)'。
'+'が入っているのは、なぜか . が 2つ入っているファイル名が若干あるためです。'+'は一つ以上の連続を表します。
import sys import os import datetime import re for filename in sys.argv[1:]: nfmt = '%Y/%m/%d %H:%M:%S-%f' print(f"(a)filename={filename}") results = re.findall('([0-9]{13})\.+(jpg|JPG|jpeg|JPEG)',filename) if (len(results)>0): unixstr = float(results[0][0])/1000 print(f"(b) unixtime ={unixstr}") uobj = datetime.datetime.fromtimestamp(unixstr) else: uobj = datetime.datetime.min print("(b) unixtimeは含まれません") udate = uobj.strftime(nfmt)[:-3] print(f"(c) udate ={udate}")
実行結果はunix時間を含む場合は、
$ python3 ~/python3/try03.py test/line_1525249363634.jpg (a)filename=test/line_1525249363634.jpg (b) unixtime =1525249363.634 (c) udate =2018/05/02 17:22:43-634
(b)の前、 results[0][0]は文字列なので、floatに変換してから1000で割って単位を秒にします。
(c)の前、%fは秒以下の表示の指示で、放置すると後ろに0を補って6桁の文字列になります。内部的にはマイクロ秒なのでしょう。全体の文字列ができてから、[:-3]を適用して文字列の末尾から3文字を削除するようにしています。これはもともとのファイル名の文字列の長さに合わせるためです。
unix時間を含まない場合は
$ python3 ~/python3/try03.py test/IMG_0953.JPG (a)filename=test/IMG_0953.JPG (b) unixtimeは含まれません (c) udate =1/01/01 00:00:00-000
となります。datetime.datetime.min はExifのときと同様です。
15桁の場合は13桁をとっています。2271年になりますから、ありえない年として後で処理します。
$ python3 ~/python3/try03.py test/line_659525353188424.jpg (a)filename=test/line_659525353188424.jpg (b) unixtime =9525353188.424 (c) udate =2271/11/06 12:26:28-424
20201123_101112.jpgとか、201123_101112.jpgのように普通の日時がファイル名に含まれているものを処理します。
この形式は色々考えられますが、実際にある例を見て上記の2つに限りました。別なものを見かけた場合、処理を増やすことにします。
正規表現は'([0-9]{8}_[0-9]{6})\.+(jpg|JPG|jpeg|JPEG)' または、'([0-9]{6}_[0-9]{6})\.+(jpg|JPG|jpeg|JPEG)',
import sys import os import datetime import re for filename in sys.argv[1:]: nrfmt = '%Y/%m/%d %H:%M:%S' print(f"(a)filename={filename}") results86 = re.findall('([0-9]{8}_[0-9]{6})\.+(jpg|JPG|jpeg|JPEG)',filename) if (len(results86)>0): D8T6str = results86[0][0] print(f"(b) YYYYMMDD_HHMMSS ={D8T6str}") d8obj = datetime.datetime.strptime(D8T6str,'%Y%m%d_%H%M%S') else: results66 = re.findall('([0-9]{6}_[0-9]{6})\.+(jpg|JPG|jpeg|JPEG)',filename) if (len(results66)>0): D6T6str = results66[0][0] print(f"(b) YYMMDD_HHMMSS ={D6T6str}") d8obj = datetime.datetime.strptime(D6T6str,'%y%m%d_%H%M%S') else: d8obj = datetime.datetime.min print("(b) 日時文字列は含まれません") d8date = d8obj.strftime(nrfmt) print(f"(c) d8date ={d8date}")
文字列が含まれる場合は次のようになります。
$ python3 ~/python3/try04.py test/IMG_20201123_101112.jpg (a)filename=test/IMG_20201123_101112.jpg (b) YYYYMMDD_HHMMSS =20201123_101112 (c) d8date =2020/11/23 10:11:12 $ python3 ~/python3/try04.py test/IMG_201123_101112.jpg (a)filename=test/IMG_201123_101112.jpg (b) YYMMDD_HHMMSS =201123_101112 (c) d8date =2020/11/23 10:11:12
含まれない場合は、
$ python3 ~/python3/try04.py test/line_1525249363634.jpg (a)filename=test/line_1525249363634.jpg (b) 日時文字列は含まれません (c) d8date =1/01/01 00:00:00
ファイルのタイムスタンプは必ずありますが、Exifはあったりなかったり、ファイル名に埋め込まれた日付もあったりなかったりです。
Exifがあるものについてはこれを採用するのがいいでしょう。次にファイル名のUnix時間または86(66)型の日時ですが、2000〜2024年の範囲なら信用することにします。最後にタイムスタンプですが、OSに問い合わせる時点でJSTになっているはずなのでこのまま使います。撮影日時ではなく保存や受信日時かもしれませんがしかたありません。この原則で"採用する日付候補"とそれぞれの日付の一覧表をつくり、妥当性を検討します。
import sys import os import datetime import gi gi.require_version('GExiv2', '0.10') from gi.repository import GExiv2 import re import pathlib def getTimeStamp(filename): ostat = (os.stat(filename)) fmtime = ostat.st_mtime #float値(Unix時刻) fmobj = datetime.datetime.fromtimestamp(fmtime) #datetimeオブジェクト return fmobj def getFromExif(filename): results = re.findall('.*\.+(jpg|JPG|jpeg|JPEG)',filename) if (len(results)>0): exfmt = "%Y:%m:%d %H:%M:%S" xiv = GExiv2.Metadata(filename) key = "Exif.Photo.DateTimeOriginal" val = xiv.get(key,'') if val != '': eobj = datetime.datetime.strptime(val,exfmt) else: eobj = datetime.datetime.min else: eobj = datetime.datetime.min return eobj def getUnixTime(filename): results = re.findall('([0-9]{13})\.+(jpg|JPG|jpeg|JPEG)',filename) if (len(results)>0): unixstr = float(results[0][0])/1000 uobj = datetime.datetime.fromtimestamp(unixstr) else: uobj = datetime.datetime.min return uobj def get86date(filename): results86 = re.findall('([0-9]{8}_[0-9]{6})\.+(jpg|JPG|jpeg|JPEG)',filename) if (len(results86)>0): D8T6str = results86[0][0] d8obj = datetime.datetime.strptime(D8T6str,'%Y%m%d_%H%M%S') else: results66 = re.findall('([0-9]{6}_[0-9]{6})\.+(jpg|JPG|jpeg|JPEG)',filename) if (len(results66)>0): D6T6str = results66[0][0] d8obj = datetime.datetime.strptime(D6T6str,'%y%m%d_%H%M%S') else: d8obj = datetime.datetime.min return d8obj def main(): print("file name\t採用する日付\tfile-timestamp\texif-date\tunixtime-fname\t86(66)-filename\t採用元") for x in sys.argv[1:]: dsorce = "none" goal = "yet" fmobj = getTimeStamp(x) eobj = getFromExif(x) uobj = getUnixTime(x) d8obj = get86date(x) if (eobj != datetime.datetime.min): goal = eobj #まずはexif dsorce = "exif" if (goal == "yet"): uyear = int(uobj.strftime('%Y')) if (uyear>1999 and 2025>uyear): goal = uobj #unixが正しそうならunix dsorce = "unixtime-from-fname" if (goal == "yet"): d8year = int(d8obj.strftime('%Y')) if (d8year>1999 and 2025>d8year): goal = d8obj #20221020_112233 等が正しそうなら dsorce = "D8_T6-from-fname" if (goal == "yet"): goal = fmobj #最後はtimestamp dsorce = "timestamp" fname = pathlib.Path(x).name fmdate = fmobj.strftime('%Y%m%d_%H%M%S-%f')[:-3] gdate = goal.strftime('%Y%m%d_%H%M%S-%f')[:-3] edate = eobj.strftime('%Y%m%d_%H%M%S-%f')[:-3] if eobj == datetime.datetime.min: edate = '-' udate = uobj.strftime('%Y%m%d_%H%M%S-%f')[:-3] if uobj == datetime.datetime.min: udate = '-' d8date = d8obj.strftime('%Y%m%d_%H%M%S-%f')[:-3] if d8obj == datetime.datetime.min: d8date = '-' print(f"{fname}\t{gdate}\t{fmdate}\t{edate}\t{udate}\t{d8date}\t{dsorce}") if __name__ == '__main__': main()
getで始まる関数群は(1)から(4)で作成した日時を求めるプログラムから、結果の表示を削除して1ファイル名を引数としてdatetimeオブジェクトを返すように変更したものです。jpgだけ拡張子をチェックする部分を付け加え、合致しなければdatetime.minを返すようにしました。
main()がプログラムのスタート地点です。上記の原則で"採用する日付候補"とそれぞれの日付を列挙します。2000〜2024年の範囲なら信用するというのもここで判断しています。日時は統一して3桁のミリ秒をつけます。datetime.minならば'-'にします。最後にどの候補から採用したのかの情報を加えタブ区切りで出力します。
実行結果例です。上記の表はこれを採用元でまとめたものです。
$ python3 ~/python3/try05.py test/* file name 採用する日付 file-timestamp exif-date unixtime-fname 86(66)-filename 採用元 1513175873396.jpg 20171213_233753-396 20200825_021838-000 - 20171213_233753-396 - unixtime-from-fname 1588653014093.jpg 20200505_133014-093 20200825_022044-000 - 20200505_133014-093 - unixtime-from-fname 1619996477265.jpg 20210503_080117-265 20210503_080116-000 - 20210503_080117-265 - unixtime-from-fname 20181004医務室.jpg 20200825_021822-000 20200825_021822-000 - - - timestamp DSC_0149.JPG 20200825_021814-000 20200825_021814-000 - - - timestamp DSC_0221.JPG 20180217_111418-000 20200825_021818-000 20180217_111418-000 - - exif IMG_0159.jpg 20200825_021824-000 20200825_021824-000 - - - timestamp IMG_0953.JPG 20171107_090100-000 20200825_021816-000 20171107_090100-000 - - exif ....以下略...
この出力をGUIでコピーをしたり、リダイレクトしてファイルにして使用するのであえてファイルへの書き出しをしていません。
実際の利用では、第二項でソートしてファイルにリダイレクトし、別のプログラムで読み込んでサムネイルを作りhtmlファイルで写真の整理を行っています。
さて、表題にある「撮影日時をExifへ書き込む」という部分です。実際には先のプログラムに続けて書いたのですが、ファイルを読んで書き込むという形に書き直そうと思います。
上記で採用された日時が不適切であったなどで書き直しが必要になる場合も考えられますし、読み出しだけをするプログラムと、書き込みまでをおこなうプログラムが同居するのは後々の管理が面倒になります。
日付を書くだけでなく、あとから追加記入したものだとわかるようにしておくことも大事だと考えて、文字データを入れられて、目に付きやすく、それなりの表題がついたタグを探しました。その結果、下記の(1)〜(6)を採用しました。
# setExif01.pyの (1/3) 解説の都合で分けました import sys import os import datetime import gi gi.require_version('GExiv2', '0.10') from gi.repository import GExiv2 import re import pathlib import argparse def writeexif(filename,exdate,dsorce,comment,softwear): metadata = GExiv2.Metadata(filename) #exdate format '%Y:%m:%d %H:%M:%S' metadata.set_tag_string("Exif.Image.DateTime",exdate) #(1) metadata.set_tag_string("Exif.Photo.DateTimeOriginal",exdate) #(2) metadata.set_tag_string("Exif.Photo.DateTimeDigitized",exdate) #(3) metadata.set_tag_string("Exif.Image.Model",f"{dsorce}2exif") #(4) metadata.set_tag_string("Exif.Photo.UserComment",comment) #(5) metadata.set_tag_string("Exif.Image.Software",softwear) #(6) metadata.save_file() # まだ続きます
gexiv2による書き込みは簡単です。filenameを使ってGExiv2.Metadata(filename)を得、.set_tag_string(key,value)で値をセットし、.save_file() で保存するだけです。ファイルのタイムスタンプは更新されます。上記のプログラムは下で示されるメインルーチンから呼ばれる関数です。
資料はhttps://exiv2.org/tags.html"Metadata reference tables" ですが、調べている最中に突然リンク切れになり(2024-02-29あたり)、2024-03-04現在リンク切れです。ブラウザに表示されたものを保存して作業を終えました。この資料に"CIPAのExif 2.3 standard"で定義されたものとありますが、CIPAはカメラ映像機器工業会規格です。現在は"Exif 3.0(CIPA DC-008- 2023)"が公開されています。さらに"Exif で用いる TIFF Rev.6.0 の付属情報"と"Exif IFD の付属情報"があって、たどるのは面倒です。多分規格が多段階に継承される過程で意図した改良や誤謬、そしてバージョンアップがあって今に至るのでしょう。
ちなにみに、CIPAではタイムゾーン(時差)を記録する場合のフォーマットは“±HH:MM”となっているので7バイトのasciiです。しかし、これが追加されるのが"Exif 2.31"なのでgexiv2の2バイト整数はその前の策定だったのかもしれません。
どんなに調べても、使用するgexiv2の仕様とさらにはそれを表示するデスクトップのソフトウェアの解釈が最終結果ですから、ほどほどにしてgexiv2で設定してデスクトップのソフトで確認するという作業で調べてみました。タイムゾーンと秒以下の時刻については今回は保留しました。
調べたソフトウェアは2つ
EOG(Eye of GNOME): GNOMEデスクトップでは"画像ビューアー"とされています。
Nautilus: GNOMEデスクトップでは"ファイル"と表記されていますが、ファイルマネージャと言ってほしいですね。
key | type | EOG | EOG-prop.-メタ | EOG-prop.-詳細 | ファイル-プロパティ | 採用No. |
---|---|---|---|---|---|---|
Exif.Image.DateTime | ascii | - | - | 画像-DateTime | - | (1) |
Exif.Photo.DateTimeOriginal | ascii | 日付時刻 | 日付時刻 | 画像-DateTimeOriginal | 画像-作成日時 | (2) |
Exif.Photo.DateTimeDigitized | ascii | - | - | 画像-DateTimeDigitized | - | (3) |
Exif.Image.Model | ascii | カメラ | カメラの形式 | カメラ-Model | 画像-カメラのモデル | (4) |
Exif.Photo.UserComment | comment | - | - | 画像-UserComment(UTF8) | 画像-説明(Ascii) | (5) |
Exif.Image.Software | ascii | - | - | カメラ-Software | 画像-ソフトウェア | (6) |
Exif.Image.Make | ascii | - | - | カメラ-Make | 画像-カメラのブランド | |
Exif.Image.ImageDescription | ascii | - | - | 画像-ImageDescription | - |
よく使用する画像ビュアーでは、まずカメラと日付・時刻が見えますが、"カメラ"が "Exif.Image.Model"、 "日付・時刻"は "Exif.Photo.DateTimeOriginal"です。カメラやスマートフォンの機種名が入りますが、Exifがないものはそもそも空欄ですから、ここにこの日付が何を根拠に記入したのかを記録することにします。主にファイル名のUnix時刻か、タイムスタンプです。日付・時刻は3つある記録場所のうちDateTimeOriginalが表示されます。
画像ビュアーのプロパティの詳細タグです。画像を右クリックしてプロパティを選択して表示できます。"カメラ"の項には、"Model"にふたたび "Exif.Image.Model"、 "Software"に "Exif.Image.Software" が表示されます。"Exif.Image.Make"を入れると"Model"の上に"Make"として表示されるのですが、採用しませんでした。
画像ビュアーのプロパティの詳細タグで、"画像データ"を展開すると、3つの日時がならびます。経験上3つとも同じ値が入っているものしか見たことがないので、習慣に従っておきます。その下の"UserComment"には"Exif.Photo.UserComment"が表示されます。EOGではUTF-8で書くと日本の文字も表示できます。
ファイルマネージャで画像を右クリックしてプロパティを選択し、さらに"画像"タブに進むと、"カメラのモデル"は "Exif.Image.Model"、 "ソフトウェア"は "Exif.Image.Software"、 "説明"は "Exif.Photo.UserComment"です。ここではUTF-8で書くと "binary comment"と書き換えられてしまいます。"作成日時"は "Exif.Photo.DateTimeOriginal"です。"Exif.Image.Make"を入れると "カメラのモデル"の上に "カメラのブランド"として表示されるのですが、採用しませんでした。
main関数に引数をつけて、チェックをしてから呼び出すようにしています。書き換えるプログラムは後から使う時に気を使いますから。
filenameはいつものように文字列のファイル名、testは実際には書き込まずに動かしてみるためのフラグです。
ファイルは上記の try05.py で出力したものを保存したものです。このファイルのうち1,2,そして最終項目を使います。このファイルは作業対象の写真とおなじフォルダに置かれているという前提です。#で始まる行と空行は無視します。
最終項目がexifである行と、ファイル拡張子から判断してjpeg以外のファイルは飛ばします。
#file name 採用する日付 file-timestamp exif-date unixtime-fname 86(66)-filename 採用元 1513175873396.jpg 20171213_233753-396 20200825_021838-000 - 20171213_233753-396 - unixtime-from-fname 1588653014093.jpg 20200505_133014-093 20200825_022044-000 - 20200505_133014-093 - unixtime-from-fname 1619996477265.jpg 20210503_080117-265 20210503_080116-000 - 20210503_080117-265 - unixtime-from-fname ....略
# setExif01.pyの (2/3) 解説の都合で分けました def main(filename,test): gfmt='%Y%m%d_%H%M%S-%f' listfile = pathlib.Path(filename) lines = listfile.read_text().splitlines() for line in lines: if len(line)==0 or line.startswith("#") : continue bname, gdate, fmdate, edate, udate, d8date, dsorce = line.split('\t') if dsorce=='exif' : continue results = re.findall('.*\.+(jpg|JPG|jpeg|JPEG)',bname) if (len(results)==0) : continue pathname = str(listfile.with_name(bname)) gobj = datetime.datetime.strptime(gdate,gfmt) exdate = gobj.strftime('%Y:%m:%d %H:%M:%S') comm = 'set exifDates with python-Gexiv2' soft = 'setExif01.py' if test : print(f"{pathname}\t{exdate}\tModel:{dsorce}\tComm:{comm}\tsoft:{soft}\tdo not write") else: writeexif(pathname,exdate,dsorce,comm,soft) print(f"{pathname}\t{exdate}\tModel:{dsorce}\tComm:{comm}\tsoft:{soft}\tdone") # まだ続きます
pathlibが活躍しています。
pathlib.Path(filename)で作成したオブジェクトを使って、listfile.read_text() でOpen...なしで読み込んでcloseまでしてくれます。.splitlines()で行で区切って行のリストにしいてます。(lines)
listfile.with_name(bname)で、"./hogepath/hugapath/filelist.txt" と "target.jpg" から "./hogepath/hugapath/target.jpg" というpathオブジェクトを作ってくれます。
GExiv2.Metadata(filename)の引数は文字列なのでstrで変換しています
ファイル中のgdateは"20171213_233753-396"といった文字列なので、datetimeオブジェクトを通して"2017:12:13 23:37:53"に変換しています。
testがTrueでない時にだけ、writeexif()関数を呼びます。
書き換えるプログラムは慎重にと、引数をチェックする部分を書いていて、argparse を見つけました。--help も自動で作ってくれるすぐれものです。
普段は、
if __name__ == '__main__': main()
と教わったまま書いていたところですが、
# setExif01.pyの (3/3) 解説の都合で分けました import argparse if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument("filename",nargs=1,help='ファイルと日付の一覧が入ったパス付きファイル名') parser.add_argument('-t', '--test', action='store_true', help='チェックだけして書きこまない') args = parser.parse_args() fname = args.filename[0] testf = args.test main(fname,testf) # ここまで
としてみました。
これだけで、
(1)引数はかならず一つ必要で要素がひとつであるリストが返るので、filenameというキーで取り出す。
(2)-tはあってもなくても良いが、あるとTrue,ないとFalseがセットされ、testというキーで取り出せる。
(3)-hまたは--helpで簡単なhelpが表示される。
をやってくれます。
引数がないと知らせてくれます
$ python3 ~/python3/setExif01.py usage: setExif01.py [-h] [-t] filename setExif01.py: error: the following arguments are required: filename
try05.pyで使っていた引数を間違えてつけると、引数の数が多くなるので注意があります。
$ python3 ~/python3/setExif01.py test/* usage: setExif01.py [-h] [-t] filename setExif01.py: error: unrecognized arguments: test/1588653014093.jpg test/1619996477265.jpg test/20181004医務室.jpg test/DSC_0149.JPG test/DSC_0221.JPG ....略...
引数をファイル名と -t のフラグの2つにすると実行します(main()を呼びます)。ファイル名と -t 順序は逆でも構いません。 -t がついているので、実際には書き込まず、最後に "do not write" をつけて表示しています。
$ python3 ~/python3/setDateTimeByUn/setExif01.py test/filelist.txt -t test/1588653014093c.jpg 2020:05:05 13:30:14 Model:unixtime-from-fname Comm:set exifDates with python-Gexiv2 soft:setExif01.py do not write test/line001.jpeg 2020:08:25 02:19:06 Model:timestamp Comm:set exifDates with python-Gexiv2 soft:setExif01.py do not write ...略...
引数をファイル名だけにすると、Exif書き込みまで行き、done と知らせてくれます。
$ python3 ~/python3/setExif01.py test/filelist.txt test/1588653014093.jpg 2020:05:05 13:30:14 Model:unixtime-from-fname Comm:set exifDates with python-Gexiv2 soft:setExif01.py done test/line001.jpeg 2020:08:25 02:19:06 Model:timestamp Comm:set exifDates with python-Gexiv2 soft:setExif01.py done
引数の-tの代わりに-xとすると、注意を受けます。
$ python3 ~/python3/setExif01.py test/filelist.txt -x usage: setExif01.py [-h] [-t] filename setExif01.py: error: unrecognized arguments: -x
引数を-hとすると、helpです。
$ python3 ~/python3/setExif01.py -h usage: setExif01.py [-h] [-t] filename positional arguments: filename ファイルと日付の一覧が入ったパス付きファイル名 optional arguments: -h, --help show this help message and exit -t, --test チェックだけして書きこまない
実行結果は、すでに上の「Exifの項目の研究」の項目に示したとおりです。一つだけ再掲しましょう。
Exif書き込み後、もう一度try05.pyを実行すると、変更前の採用する日付のとおり(秒以下は切り捨てられますが)Exifが書き込まれ、タイムスタンプが書き込んだ時間になっていることがわかります。Exifが入ったことで変更後は採用元が全部exifになりますから、2度目の実行をしても無駄な書き込みはありません。
前後 | file name | 採用する日付 | file-timestamp | exif-date | unixtime-fname | 86(66)-filename | 採用元 |
---|---|---|---|---|---|---|---|
変更前 | 1588653014093.jpg | 20200505_133014-093 | 20200825_022044-000 | - | 20200505_133014-093 | - | unixtime-from-fname |
変更後 | 1588653014093.jpg | 20200505_133014-000 | 20240303_233904-000 | 20200505_133014-000 | 20200505_133014-093 | - | exif |
前後 | file name | 採用する日付 | file-timestamp | exif-date | unixtime-fname | 86(66)-filename | 採用元 |
変更前 | line001.jpeg | 20200825_021906-000 | 20200825_021906-000 | - | - | - | timestamp |
変更後 | line001.jpeg | 20200825_021906-000 | 20240303_233904-000 | 20200825_021906-000 | - | - | exif |