UNIX時間から得た撮影日時をExifへ書き込む

目次

公開日 2024-03-05 更新日 -

概要

スマートフォンから取り出した写真にExifの撮影日時データがないものがたんさんありました。1608975591739.jpgなどのファイル名から日時を取り出せます。なんとか撮影日時を(無理なら保存日時を)Exifに書き込んで写真の整理に役立てようというお話です。

Debian11上のpython3を端末上で使っています。Pythonのバージョンは3.9.2です。Debian12の環境でもほとんど変わりません。

pythonの使用例として、bashの変数展開した引数の使用、datatimeオブジェクト、ファイルのタイムスタンプの利用、Exifデータの読み書き、正規表現、pathlibによるファイルの読み込み、argparseによる引数の管理 などに触れています。

動画については、次回。

その前夜・Exifは大切という話

デジタルカメラの写真を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には写真です。

LINEの写真にはExif情報がない

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時間もわからないようになっていくのかもしれませんね。

1607847545223.jpgから日時を取り出す

採用する日付: 以下のタイムスタンプ、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-timestampexif-dateunixtime-fname86(66)-filename採用元
DSC_0221.JPG20180217_111418-00020200825_021818-00020180217_111418-000--exif
IMG_0953.JPG20171107_090100-00020200825_021816-00020171107_090100-000--exif
DSC_2823.JPG20230821_132851-00020230821_222852-19020230821_132851-000--exif
IMG_20201123_101112.jpg20201123_101113-00020200825_022720-00020201123_101113-000-20201123_101112-000exif
IMG_20170316_153212.jpg20170316_153213-00020201211_163656-00020170316_153213-000-20170316_153212-000exif
1513175873396.jpg20171213_233753-39620200825_021838-000-20171213_233753-396-unixtime-from-fname
1588653014093.jpg20200505_133014-09320200825_022044-000-20200505_133014-093-unixtime-from-fname
1619996477265.jpg20210503_080117-26520210503_080116-000-20210503_080117-265-unixtime-from-fname
1607847545223.jpg20201213_171905-22320201213_171905-000-20201213_171905-223-unixtime-from-fname
1633006447433.jpg20210930_215407-43320210930_215407-000-20210930_215407-433-unixtime-from-fname
1618913852784..jpg20210420_191732-78420210420_191732-000-20210420_191732-784-unixtime-from-fname
1633260266356.jpg20211003_202426-35620211003_202426-000-20211003_202426-356-unixtime-from-fname
1707553778311.jpg20240210_172938-31120240210_172938-000-20240210_172938-311-unixtime-from-fname
line_1525249363634.jpg20180502_172243-63420200825_022720-000-20180502_172243-634-unixtime-from-fname
line_1575636357022.jpg20191206_214557-02220200825_022722-000-20191206_214557-022-unixtime-from-fname
line_1578120547741.jpg20200104_154907-74120200825_022740-000-20200104_154907-741-unixtime-from-fname
line_1565966289093.jpg20190816_233809-09320201211_163819-000-20190816_233809-093-unixtime-from-fname
line_1573206174253.jpg20191108_184254-25320201211_163830-000-20191108_184254-253-unixtime-from-fname
line_1579855693867.jpg20200124_174813-86720201211_163900-000-20200124_174813-867-unixtime-from-fname
CameraIntent_1651908399343.jpg20220507_162639-34320220507_162753-000-20220507_162639-343-unixtime-from-fname
20181004医務室.jpg20200825_021822-00020200825_021822-000---timestamp
DSC_0149.JPG20200825_021814-00020200825_021814-000---timestamp
IMG_0159.jpg20200825_021824-00020200825_021824-000---timestamp
line_297274030291617.jpg20200825_022128-00020200825_022128-000-22000704_125811-617-timestamp
line_659525353188424.jpg20200825_021906-00020200825_021906-000-22711106_122628-424-timestamp

プログラムの部品

ここからは、まず使用する部品ごとに説明して、使用例を示します。

(0) bashの変数展開で引数を受け取る

一つのファイルで試したあと、フォルダ内の全部のファイルの処理をするなどの場合に、これを使うと便利です。

つまり、例えば、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

(1) タイムスタンプ関係

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を使っていくのが吉です。

(2) Exif関係

ここでは、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')}")

(3-α) unix時間を取り出す正規表現

これからやろうとしているのはこんなファイル名です。

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) ファイル名に記録されているunix時間

(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

(4) 普通の日時がファイル名に含まれている場合

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へ書き込む部分

さて、表題にある「撮影日時をExifへ書き込む」という部分です。実際には先のプログラムに続けて書いたのですが、ファイルを読んで書き込むという形に書き直そうと思います。

上記で採用された日時が不適切であったなどで書き直しが必要になる場合も考えられますし、読み出しだけをするプログラムと、書き込みまでをおこなうプログラムが同居するのは後々の管理が面倒になります。

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() で保存するだけです。ファイルのタイムスタンプは更新されます。上記のプログラムは下で示されるメインルーチンから呼ばれる関数です。

Exifの項目の研究

資料は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"を入れると "カメラのモデル"の上に "カメラのブランド"として表示されるのですが、採用しませんでした。

Exif書き込みを呼び出す部分

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()関数を呼びます。

main()を呼び出す部分

書き換えるプログラムは慎重にと、引数をチェックする部分を書いていて、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が表示される。

をやってくれます。

argparseの活躍ぶり

引数がないと知らせてくれます

$ 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-timestampexif-dateunixtime-fname86(66)-filename採用元
変更前1588653014093.jpg20200505_133014-09320200825_022044-000-20200505_133014-093-unixtime-from-fname
変更後1588653014093.jpg20200505_133014-00020240303_233904-00020200505_133014-00020200505_133014-093-exif
前後file name採用する日付file-timestampexif-dateunixtime-fname86(66)-filename採用元
変更前line001.jpeg20200825_021906-00020200825_021906-000---timestamp
変更後line001.jpeg20200825_021906-00020240303_233904-00020200825_021906-000--exif