pythonを使って溜まりに溜まりまくる写真を整理整頓してみる

<!-- vi: se tw=1000 noet sw=2 ts=4 sts=2 et wrap nolbr : -->

# 概要
pythonを使って、Exif情報をもとに写真を整理整頓してみようというお話です。

ググってわかるようなことしかやっていませんが、ググってわかるようなことを組み合わせてこんなことをやってみたよ、という内容です。

最後にコードを掲載しています。
途中の記事では適当なサンプルに留めて、python使ってみた感想文みたいになっています。

python初学者が書きました。

# 実現したいこと

個人的な写真整理方法で恐縮なのですが、方針としては以下になります。
- 10年以上前からの写真があるので、まずは年単位のフォルダ「yyyy」で大雑把に区切る。
- 日付別フォルダの下で、さらにカメラモデル別に写真を格納する。
- 日付別のフォルダ名は、「yyyymmdd_」の後ろに出来事などが端的にわかる形にする。
- 後から付け足すこともできるようにする。

フォルダ構成は、以下のようなイメージになります。
```
photo/
├─2009/
│  ：
└2023/
  ├─20230101_○○神社初詣/
  │  ├─Canon PowerShot G7 X Mark III/
  │  │  ├─IMG_xxxx.JPG
  │  │  ├─IMG_xxxx.CR3
  │  │  └─MVI_xxxx.MP4
  │  ├─SOV42/
  │  │  └─DSC_xxxx.JPG
  │  └─RICOH GR III/
  │      ├─Rxxxxxxx.JPG
  │      └─Rxxxxxxx.DNG
　：
```

最悪の場合、年単位フォルダの直下に365個の年月日サブフォルダが並びますが、そんなに毎日写真を撮ることもないでしょう。

見ていただくとわかる通り、2023年1月1日のフォルダ名が「20230101_○○神社初詣」になっています。
本当はファイルシステム上でこういうことをしないほうがいいのかもしれません。（しかも日本語で）

こういうことをしているせいで、「後から付け足す」という方針を満たすために、exifの撮影日＝先頭8桁というフォルダが存在した場合には、そこにマージする必要が出てきます。

一つのカメラから写真を取り出してPCに保存したときに、年月日フォルダ名末尾に`yyyymmdd_ほげほげ`と注釈を追加します。（時間が経つほど忘れちゃいますので）

本来なら、同日に全部の写真をPCに取り込みめばよいのですが、大体やりません。
別の機会に、別のカメラやスマホから写真を取り出した時にも、人から写真を受け取ったりした時にも、年月日フォルダ単位では勝手にマージされるようにしたいのです。


# 環境
OS
・Windows       10, 11

開発環境
・Python        3.11, 3.12

外部ツール
・exiftool      12.69

導入したPythonモジュール
・piexif        1.1.3
・progressbar2  4.2.0



# 環境構築
Python自体のインストール説明は、割愛します。
あと、exiftoolも、展開したらファイル名から(-j)を取っ払って、pathを通しておくのを忘れないようにしてください。

あとは、必要なモジュールを追加しておいてください。
```
pip install --upgrade pip setuptools
pip install piexif
```

実はもうインストールされているかもしれないので、念のため確認してからにした方が良いかもしれませんね。
```
pip list
```

# 作ってみる

## フォルダ配下ファイルの列挙
まず最初は、pythonって書くの楽チーン、とか無邪気に楽しみながら他言語のノリで、os.path.listdir()の繰り返しの中で、フォルダを再帰するような処理を書いてしまいました。

なんとPythonには、そういう時に使うものとして、glob.glob() や os.walk() といった便利なものが存在しているようです。

前者はお手軽に全部の配下ファイルを対象にしたい場合に便利で、後者はこのフォルダのファイルにはこの処理、そのフォルダのファイルにはその処理～、みたいなことが簡単にやれちゃいそうな気がします。
glob.globだと、os.path.dirname() とか os.path.basename() とかで切り分けたり、フォルダを跨いだ場合の処理も自前で考えないといけなさそうですし？

とりあえず今回は、手っ取り早そうな glob を使ってみました。

まずは、glob.glob() でループしつつ、ファイルだけを対象に、ホゲホゲする。
というメインループともいうべき部分が出来上がりました。


3行です。
```.py
    for path in glob.glob(target_folder_name+'/**', recursive=True):
        if os.path.isfile(path):        #ファイルだけを対象
            print('ホゲホゲ',path)
```
もはや、楽チンとかそういういうレベルではないと思いました。

実際には、ドットファイルを取得するために、もうひと手間かかっています。


## Exif情報取得
Pillow、ExifRead、pyexiv2、piexif、等を使ってExif情報を取り出してみました。

それぞれ使い方は微妙に違いましたが、どれでもだいたい同じように情報を取ることができました。
撮影時刻とモデルが欲しいだけで、それほど難しい使い方はしていませんので。

結局、piexif （最終的には、piexif と exiftool の二本立て）に落ち着きました。
※その他のモジュールについては、参考として文末に軽く記載しておきます。

pyexif の利用はこんな感じです。
```.py
import piexif
def test_piexif(path):
    exif = piexif.load(path)
    print(datetime.strptime(str(exif['Exif'][36867],'utf-8'), '%Y:%m:%d %H:%M:%S').date().strftime('%Y%m%d')
    print(str(exif['0th'][271],'utf-8'))
    print(str(exif['0th'][272],'utf-8'))

test_piexif(r'c:\spam.jpg')
```

便利なものが多数用意されている半面、やりたいことをするために、どれをどうやって使うのか、というのがキモになる気がしました。


## クラスを使う
Python は、クラスも使えるんですって。

さっそく試してみました。

処理対象になるファイルの情報を保持する親クラスを作って、ひとまずは以下の3種類の子クラスを用意しました。

- カメラファイル（親）
  - 写真ファイル（exifを持ってそうな画像ファイル）
  - 写真以外のファイル（とりあえず写真と一緒に移動させたいファイル）
  - 対象外ファイル（写真とは関係ないどこに移動させていいのかわかんないファイル）

```.py
#カメラファイル
class CameraFile():
    def __init__(self,dirname,filename,exe):
        self.dirname  = dirname
        self.filename = filename
        self.exe      = exe
        ...

#写真ファイル
class PhotoFile(CameraFile):
    def __init__(self,dirname,filename,exe):
        super().__init__(dirname,filename,exe)
        ...

#写真以外ファイル
class NotPhotoFile(CameraFile):
    def __init__(self,dirname,filename,exe):
        super().__init__(dirname,filename,exe)
        ...

```
class の宣言時のカッコの中には、親クラスを書きます。


パスを引数に渡して生成すると、みんなそれぞれどこに移動させてほしいのか答えてくれるようにします。
拡張子をどの子クラスを作るかの判定で先に使うため、事前にフォルダとファイル名と拡張子を分割済みなので、コンストラクタにはその形で渡しています。

お手軽に拡張子で振り分けて子クラスのオブジェクトを作り分けながら、ジャンジャン生成してはリストに全部ブチ込んでいきます。


写真ならexifをもとに日付とモデルから行きたい場所を答えるようにします。
※動画ファイルもExifを持っているみたいですが、めんどくさいので写真だけを対象にしました。

写真と一緒に動かしたいファイルなら、自分自身の更新日と、同一ディレクトリに一緒に存在している写真と同じモデルだろうってことで、一緒にそこに連れて行ってくれー、と言ってくるようにします。同一ディレクトリ内に動画しかなかった場合は、モデルがわからず、日付フォルダまではついてくるようにします。

子クラスとしては、自分の撮影日付とモデルがわかったら、親クラスの行先決定処理を呼び出すだけにしておきます。
そうしておくことで、日付の8桁と一致するフォルダに入りたいんじゃい！という一族の方針を守ってくれます。

関係なさそうなファイルは、一応リストアップするようにオブジェクトを生成しておくだけで、移動先はunknownにしておきます。



全員リストに詰め込み終えたら、各自どこに格納してほしいのかを聞いていけばＯＫです。
筋道が見えてきました。


どうも、同一親クラスの派生クラスのインスタンスだから（とか、同一インターフェースを持っているから）リストに突っ込んで、回しながら同じように扱える…というよりは、派生クラスのインスタンスの参照がリストに詰まっているだけで、まぁ、メソッド呼び出しは、そのオブジェクトをselfに渡してただ呼んでいるだけ、という感じなんでしょうか？試していないのでよくわかりませんが。

## CSVファイル出力

各自が自分をどこに詰め込んでほしいのか答えられるようになったら、その情報をもとに一気に shutil を使ってファイル移動を行う。
・・・というような実装は怖くていけません。

とりあえず、一覧を確認できるようにしたいので、結果をファイル出力するようにしました。

csvモジュールという標準ライブラリで、パッとできました。

CSVファイルの出力サンプルはこんな感じです。
リストを1行として書きだしたり、リストのリストを複数行として書きだしたりできちゃいます。便利～。
花子7才、太郎5歳。
```.py
import csv

header = ['name','age']
body=[['hanako',7],
      ['taro'  ,5]]
with open(r'C:\myData\temp\egg.csv','w') as f:
    writer= csv.writer(f,lineterminator='\n')
    writer.writerow(header)
    writer.writerows(body)
```



csvファイルを開いて確認したら、再びcsvモジュールを使ってリストに詰め込んで、リストをグリグリ回しながらファイル移動を実行させるようにすればよさそうです。
なんだかもうゴールが見えてました。

CSVファイルの入力サンプルはこんな感じです。
```.py
with open(r'C:\myData\temp\egg.csv') as f:
    reader=csv.reader(f)
    csv_list=list(reader)
header = csv_list[0]
del csv_list[0]           #ヘッダは削除しておく
print (csv_list)
```

後に知ったのですが、CSVを辞書に読み込んでくれるDictReaderというものがあるらしいです。
動いているのでわざわざ直しませんが、ヘッダを無条件に消し去って、インデックスでコネコネするバカみたいなコードになっているので要注意です。（もし修正することがあったら直すかも。）

## 外部プログラム実行
どうにも、SONY の CR3 形式から、Exif情報をうまく取ることができなかったので、Exiftoolのチカラを頼ることにしました。

これまたググると出てくる情報ですが、subprocess モジュールを使って起動します。

exiftool に `-j` オプションをつけて、JSONで結果を返してもらいます。
標準ライブラリのjsonモジュールで辞書としてゲットできます。

便利が過ぎる。。

標準エラーを出さないようにしたりしています。


```.py
import subprocess , json
result = subprocess.run(['exiftool', '-j', self.path], stderr=subprocess.STDOUT, stdout=subprocess.PIPE, encoding='utf-8',text=True,check=True)
exif= json.loads(result.stdout)     #標準出力をJSONとして解析

print(datetime.strptime(str(exif[0]['DateTimeOriginal']), '%Y:%m:%d %H:%M:%S').date().strftime('%Y%m%d'))
print(exif[0]['Model'])
```

## 一時フォルダの作成
これで CR3ファイルも行ける！と思っていろいろ試していたら、一部の日本語を含むパスだと exiftool がちゃんと動けなかったりする事に気が付きました。

普通にコマンドプロンプトから exiftool を実行しても、解析できていないので、python からの呼び出し方がどうのこうのというレベルではなさそうでした。

ということで、うまくExifが取れなかった場合は、一時フォルダを作ってコピーしてからリトライするようにしました。

こんな感じです。
```.py
with tempfile.TemporaryDirectory() as tmpdir:   #一時フォルダに、
    shutil.copy(self.path, tmpdir)              #コピーする。
    tmpfile=os.path.join(tmpdir,self.basename)  #コピーしたファイルを元に、Exif取得。
    result = subprocess.run(['exiftool', '-j', tmpfile], capture_output=True, encoding='utf-8')
```

一時フォルダを作って、使い終わったら、中に入れたファイルごと自動で消してくれるというモジュールが、標準ライブラリに入っているとは、・・・もう快適すぎてダメ人間になりそうです。

ついでに、スキャナから読み込んだファイルが混じっていたりして、exif辞書からキーがうまく取れなかったりした場合に、場当たり的に回避させてあります。（もう少しキレイな処理に修正したいところです）

## 進捗状況表示
exiftoolを使うようにして、日本語パスにも対処して、どんなファイルもどんとこいと意気揚々、大量ファイルを食わせてみたら、ちっとも終わりません。

どのくらいのスピードで処理しているのか見たくなったので、進捗状況を表示できるようにしてみたいと思いました。

これまた複数の有名どころモジュールが居るようですが、一番最初にヒットした progressbar2 を使ってみました。
使い方は超簡単でした。ビックリしました。どれくらい簡単かというと、説明することが特にないくらい。（表示形式を変えたりしたかったら、もう少し調べ物も必要なのでしょうが）

進捗表示してみた結果わかったことといえば、1ファイルあたり、0.8秒くらいかかっていました。

とりあえず、ありったけの写真を全部かけてみようと思い、一晩中動かしておいてみたのですが、6万弱のファイルに対して、13時間以上かかりました。
Exifを取得するだけで、です。（CSVに書き込む方にも進捗表示を付けておいたのですが、そっちは一瞬で終わってました）

途中で異常終了しないということが分かっただけでも収穫です。


## スレッドの利用
結局、CR3の場合だけ exiftool を使うことにして、それ以外の場合は piexif というスタイルに落ち着きました。

動くようにはなったのですが、進捗バーがCR3ファイルに到達するとガッと止まって、しばらくするとまたガーと進んでいくのが目に見えるようになりました。
CPUはかなり遊んでいます。

ファイルを集めている時点ではまだExif解析まで終わってなくてもよくて、対象ファイルが全部がそろった後、移動先を聞くときまでにExif解析は終わってれば良いのです。
ファイルを集める手を止めてまで、遅い外部コマンドの終了を待っているのは時間の無駄です。

そこで、exiftool を subprocess してる処理は別スレッドにやらせておいて、ファイルを集めるメインスレッドは止めずにズンズン進んでいけばよいのではないかと思いました。
少なくとも最初のウチに出くわす CR3 ファイルは、他のファイルをせっせと集めている間に、裏で exiftools を終わらせておいてくれそうな気がします。

これまた、簡単にできちゃうんだもんなー。
スレッドのサンプルです。
```.py
ret=[None]*1    #戻り値入れ物
th=threading.Thread(target=メソッド名, args=(ret))
```
th が終わるまで待機したいときには、th.join() です。
メソッド中でret[0]を詰め替えることで、スレッドから戻り値を得ることができます。

調子に乗ってスレッドを作らせまくったら、CPU使用率が100%に張り付いて、放置しておいて画面がロックされた後に、ロック解除しようとしても反応が激重になってしまったので、控えめに5多重にしておきました。
CPU使用率もいい感じです。

多重度の指定まで、こんなに簡単にできていいのか？これで本当にあっているのか？、と不安になるレベルで簡単です。
こんな感じで、このメソッドは、5多重までに制限されます。
```.py
semaphore = threading.BoundedSemaphore(5)   #5多重まで許容。
def メソッド名(ret):
  semaphore.acquire()
  ：
  semaphore.release()
```

もう、これ以上かみ砕いて説明することができないくらいにやりたいことをそのまま書けます。
なんか、余計な準備とか拵えとか何にもいらないの。

結局、5多重程度では、 ファイル収集が終わった時にもまだまだ CR3 ファイルの解析待ち行列が終わらず、移動先を聞いて回るループにも進捗表示を付けることにしました。

対象ファイルを集める進捗は一定速度で進んで終わるのですが、行先を聞いて回る方の進捗バーはガッと80%位まで一気に進んで、そこからジリジリと進むという変な動きをするようになりました。

## 対応形式を増やす
SILKYPIX というRAW現像ソフトのファイルが、宛先不明で元の写真と泣き別れてしまうという事が判明しました。
「元ファイル.spd」という名前なので、元ファイルを頼りに`SILKYPIX_DS`フォルダごと引っ越しするように対処しました。

子クラスの種類を増やして、引っ越し先の答え方だけ、ちょっと違う風に書いただけで対応完了。
たいして使いこなしていないけど、オブジェクト指向万歳！


## ファイル移動

ここまでで、移動させたい先の一覧は出力できるようになりました。

実は最初は、作成できたCSVをもとに、moveするbatファイルでも書けば、もういいんじゃね。これで完成じゃね。やりきったんじゃね。とか思ってました。
（「CSVファイル出力」の項で書いたことと、ここで言っていることが違っていますが、実はこのあたりから少しめんどくさくなってきました。）

いざ、bat ファイルを手作業で作ってみようとしたところ、移動先が重複している同名ファイルとかあるし、確認するのメンドクサーと思ったので、作成したCSVファイル内で移動先が被った同名ファイルが居るかどうかも併せて出力するようにして、さらに、CSVに乗らないファイルでも元々その移動先に存在するファイルとも被っていないかどうかの確認までするようにしました。

なんかここまでやったら、もうファイルの移動までこのツールで自前でやってしまおうという気になって、shutil モジュールを使ってファイル移動まで行うようにしました。

ファイル移動にも進捗バーの表示を足してみました。

1つのメソッドの中にダラダラと処理を書いてしまっているのは、目的がフワフワしていたせいです。と言い訳。
手作業で何かするめんどくささよりも、ツールを書くめんどくささのほうがマシですね。

## 引数を読み込む

実は今までは、どのフォルダを対象にファイルを集めて、どこに向かって移動させるのか、という条件をベタ書きで書き直しては指定していました。

流石にそのままでは使い物になりません。そろそろパラメータを指定できるようにしないとね。
・・・ということで、sys.argv を使って引数から移動元、移動先を指定できるようにしました。

以下のような形に落ち着きました。
・引数が二つなら、CSVを吐いて確認を促して、処理続行を問い合わせ。yes ならファイルの移動まで実行。
・引数が一つなら、指定されたCSVをもとに移動を実行。（読み込んだCSVの確認は一応実施する）


## 使い方
バッチファイルからこんな感じで呼び出して使っています。
バッチファイルは当然ですが、ms932で保存します。
```.bat
python move_files_by_exif.py "c:\temp\写真一時置き場" "D:\写真整理"
```


# 出来上がったもの
だいぶ端折ってここまで書いてきましたが、実際に出来上がったコードはこんな感じでございます。

コードは空行込みで約600行。意外に行数あったな、という気持ちです。
もっとチョロっと書きあがったような気分。

.pyファイルは、utf-8で保存しましょう。
<details><summary>move_files_by_exif.py</summary>

```move_files_by_exif.py
from pprint import pprint
import os,sys
import glob
import csv
import shutil  #moveとか。
from datetime import datetime
import re
import piexif
import subprocess , json ,tempfile      #CR3用にExifToolを起動する。
from progressbar import ProgressBar
import threading
import time


################################################################################
#対象ファイル収集
def file_collection(dirname):
    print('対象ファイル収集(', dirname, ')')

    old_ishidden = glob._ishidden                           #ヒドゥン判定を退避。
    glob._ishidden = lambda x: False                        #ドットファイルでもFalse。
    path_list = glob.glob(dirname+'/**', recursive=True)
    glob._ishidden = old_ishidden                           #ヒドゥン判定を戻しておく。

    #プログレスバーの最大値は、配下の全ディレクトリ・ファイル。
    pb=ProgressBar( maxval=len(path_list) )
    i=0
    pb.start()

    for path in path_list:
        if os.path.isfile(path):        #ファイルだけを対象
            wkFile=cameraFileFactory(path)
            if wkFile != None:
                targetfiles[wkFile.path] = wkFile
        i+=1                            #プログレスバー更新
        pb.update(i)

    pb.finish()                         #収集プロセス完了

################################################################################
def cameraFileFactory(path):
    #■撮影日、型番をExifから取得して、移動先パスを生成。
    # .JPG .DNG .CR[23] .ARW    Exifありファイル。

    #■撮影日＝ファイル更新日。型番は同一ディレクトリの他ファイルのExifから取得する。
    # .MTS .mp4 .MP4 .MOV       動画
    # .MPO                      立体視用

    #■サブディレクトリ「SILKYPIX_DS」に、元ファイル名+～.spd、という名前。日付・型番を元ファイル名のexifから取得。
    # .spd                      SILKYPIX

    #■ファイル更新日が、撮影日時と関係ない可能性があるファイル。移動先不明としてリストアップ
    # .png                      Exifは持たない（ことが多い。）加工したファイルとかが一緒に紛れている場合？
    # その他拡張子              txtやcsv（csvは多分このツール自身が吐いたファイル？）もここに含めておく。

    #■無視するファイル。
    # Thumbs.db                 Windowsサムネ
    # copy.csv                  本ツールのoutput

    #TODO:拡張子以外に、もっと確実で簡単な識別方法が何かあれば。


    dname,fname,exe=splitPath(path)
    if exe.lower() in('.jpg','.jpeg','.dng','.cr2','.arw'):
        return PhotoFile(dname,fname,exe)

    elif exe.lower() in('.cr3'):
        return PhotoFile_exiftool(dname,fname,exe)

    elif exe.lower() in ('.mpo','.mts','.mp4','.mov','.avi','.wmv','.mp3','.m4a','.ac3','.wma'):
        return NotPhotoFile(dname,fname,exe)

    elif exe.lower() in ('.spd'):
        return SilkyPixFile(dname,fname,exe)

    elif (fname+exe).lower() in ('thumbs.db','copy.csv'):
        return None

    elif fname.lower() == 'move_files_by_exif':     #自分自身も無視するように。
        return None

    else:
        #ファイルの作成日や更新日が、撮影日と関係があるとは限らないため、日付が不明。もちろん型番も不明。
        #リストアップはするがどこに動かしたらいいかわからないファイル。
        return NotSupportedFile(dname,fname,exe)

################################################################################
def splitPath(path):
    dirname=os.path.dirname(path)                  #ディレクトリ
    basename=os.path.basename(path)                #ファイル名.拡張子
    filename,exe=os.path.splitext(basename)         #ファイル名と拡張子
    return dirname,filename,exe

################################################################################
def output_result():
    header = ['元パス','ファイル名','拡張子','移動先','移動先重複','既存ファイル存在']
    body=[]

    ret=[0,0,0]   #重複有無、不明有無、上書き検出

    #################################################
    print('情報収集')
    pb=ProgressBar( maxval=len(targetfiles) )
    i=0
    pb.start()


    for x in targetfiles.values():
        try:
            dataline=[x.dirname,x.basename,x.exe,x.move_to]
        except:
            print('OUTPUT ERROR!:',x.path)
            raise
        body.append(dataline)
        i+=1                            #プログレスバー更新
        pb.update(i)                    #move_to内でjoin待ちがあるので途中から遅くなるはず。

    pb.finish()


                                        #以降は一瞬で終わるはず。
    #################################################
    #キー収集
    newpath_list=[]
    newpath_dict={}
    for x in body:
        newpath = os.path.join(x[3],x[1])
        newpath_list.append(newpath)
        newpath_dict[newpath]=0         #辞書にキー追加

    #################################################
    #重複集計
    for x in newpath_list:
        newpath_dict[x] += 1
        if newpath_dict[x] > 1:         #リスト内に重複しているファイルが存在する場合、
            ret[0]=1                    #重複アリ戻り値
        if 'unknown' in x:
            ret[1]=1                    #不明アリ戻り値

    #################################################
    #出力内容整形
    for i in range(len(body)):
        oldpath = os.path.join(body[i][0],body[i][1])
        newpath = newpath_list[i]

        body[i].append(newpath_dict[newpath])       #リスト内重複数を追加。
        if os.path.exists(newpath):
            if oldpath == newpath:
                body[i].append('格納場所の変更無し')  #移動する必要のないファイル
            else:
                body[i].append('既存ファイルを上書きする可能性あり')      #整理先フォルダに既存ファイル有を追加。
                ret[2]=1                            #上書き検出戻り値
        else:
            body[i].append('')

    #################################################
    print('')
    print('結果出力')
    with open(copy_csv,'w') as f:
        writer= csv.writer(f,lineterminator='\n')
        writer.writerow(header)
        writer.writerows(body)
    print(copy_csv)
    print('')
    return ret


################################################################################
#カメラ関連ファイルクラス
################################################################################
class CameraFile():
    def __init__(self,dirname,filename,exe):
        self.dirname  = dirname
        self.filename = filename
        self.exe      = exe

    ################################################################################
    @property
    def path(self):
        return os.path.join(self.dirname, self.basename)

    ################################################################################
    @property
    def basename(self):
        return self.filename + self.exe

    ################################################################################
    @property
    def move_to(self):
        #もし、格納フォルダ名が、dateのyyyymmddと部分一致している場合は、
        #格納フォルダ名はそのままでOKにしたい。

        date_pattern = re.compile(self.date)                                #自分自身の年月日'yyyymmdd'検索用

        newDirName=''

        yyyy=datetime.strptime(self.date,'%Y%m%d').date().strftime('%Y')    #年はそのまま使う。
        dir_yyyy = os.path.join(photo_store_dst,yyyy)
        if os.path.exists(dir_yyyy):                                        #該当年ディレクトリが存在する場合、
            for day_folder in os.listdir(dir_yyyy):                         #該当年フォルダ配下のすべての
                if os.path.isdir(os.path.join(dir_yyyy,day_folder)):        #サブディレクトリについて、
                    mo = date_pattern.search(day_folder)                    #先頭が日付一致するかどうか確認。
                    if mo != None:                                          #マッチした場合は、
                        newDirName = day_folder                             #そのままフォルダ名に使用して、
                        break                                               #年フォルダ内の繰り返しを終了。

        if newDirName == '':                                                #一致するサブフォルダが見つからなかった場合は
            newDirName = self.date

        return  os.path.join(photo_store_dst, yyyy, newDirName, self.model)



################################################################################
# exiftoolを使用してExifデータを抽出
semaphore = threading.BoundedSemaphore(5)   #5多重まで許容。
def get_exif_exiftool(self,ret):
    #print(self.path)

    ret_list=[] #戻り値リスト（date,make,model）

    ##スレッドはバンバン作ってメインスレッドは先に進んでもらうけど、subprocess起動するのは待つ。
    semaphore.acquire()

    try:
        result = subprocess.run(['exiftool', '-j', self.path], stderr=subprocess.STDOUT, stdout=subprocess.PIPE, encoding='utf-8',text=True,check=True)
    except Exception as err:
        with tempfile.TemporaryDirectory() as tmpdir:   #一時フォルダに、
            shutil.copy(self.path, tmpdir)              #コピー
            tmpfile=os.path.join(tmpdir,self.basename)  #したファイルを元に、Exif取得。
            result = subprocess.run(['exiftool', '-j', tmpfile], capture_output=True, encoding='utf-8')

    try:
        exif= json.loads(result.stdout)     #標準出力をJSONとして解析
    except Exception as err:
        print(f"Unexpected {err=}, {type(err)=}")
        print(self.path)
        raise

    try:
        date  = datetime.strptime(str(exif[0]['DateTimeOriginal']), '%Y:%m:%d %H:%M:%S').date().strftime('%Y%m%d')
    except KeyError:
        date =str(exif[0]['FileModifyDate'])
        if '+' in date:
            date = date[0:date.index('+')]
        date  = datetime.strptime(date, '%Y:%m:%d %H:%M:%S').date().strftime('%Y%m%d')

    try:
        make  = exif[0]['Make']
    except KeyError:
        make  = 'unknown'

    try:
        model = exif[0]['Model']
    except KeyError:
        model = 'unknown'

    ret_list.append(date)
    ret_list.append(make)
    ret_list.append(model)
    ret[0]=ret_list

    semaphore.release()

################################################################################
#写真ファイルクラス
################################################################################
class PhotoFile(CameraFile):

    ################################################################################
    def __init__(self,dirname,filename,exe):
        super().__init__(dirname,filename,exe)
        targetdirs[dirname]=self   #ディレクトリをキーに、写真ファイルを保持。（1ディレクトリを代表して1ファイル。）
        self.get_exif()


    ################################################################################
    def get_exif(self):
        self.join_flg=False

        try:
            exif = piexif.load(self.path)

            self.date = datetime.strptime(str(exif['Exif'][36867],'utf-8'), '%Y:%m:%d %H:%M:%S').date().strftime('%Y%m%d')
            self.make  = str(exif['0th'][271],'utf-8')
            self.m_model = str(exif['0th'][272],'utf-8').strip()

        except (KeyError, piexif._exceptions.InvalidImageDataError):
            self.join_flg= True
            self.ret=[None]*1
            self.th=threading.Thread(target=get_exif_exiftool, args=(self,self.ret))
            self.th.start()
        except:
            print('')
            print('■■■',self.path,'■■■')
            print('')
            raise

    ################################################################################
    @property
    def model(self):
        if self.join_flg:
            self.th.join()
            self.m_model = self.ret[0][2]
        return self.m_model

    @model.setter
    def model(self,p):
        self.m_model=p


    ################################################################################
    @property
    def move_to(self):
        if self.join_flg:
            self.th.join()  #exiftoolの終了を待つ必要あり。
            self.date  = self.ret[0][0]
            self.make  = self.ret[0][1]
            self.m_model = self.ret[0][2]

        return super().move_to


################################################################################
#写真ファイルクラス（CR3）
################################################################################
class PhotoFile_exiftool(CameraFile):

    ################################################################################
    def __init__(self,dirname,filename,exe):
        super().__init__(dirname,filename,exe)
        targetdirs[dirname]=self   #ディレクトリをキーに、写真ファイルを保持。（1ディレクトリを代表して1ファイル。）
        #self.get_exif()

        self.ret=[None]*1    #戻り値入れ物
        self.th=threading.Thread(target=get_exif_exiftool, args=(self,self.ret))
        self.th.start()

    ################################################################################
    @property
    def model(self):
        self.th.join()
        self.m_model = self.ret[0][2]
        return self.m_model

    @model.setter
    def model(self,p):
        self.m_model=p

    ################################################################################
    @property
    def move_to(self):
        self.th.join()  #自分がCR3の場合は、exiftoolの終了を待つ必要あり。
        self.date  = self.ret[0][0]
        self.make  = self.ret[0][1]
        self.m_model = self.ret[0][2]

        return super().move_to


################################################################################
#写真以外ファイルクラス
################################################################################
class NotPhotoFile(CameraFile):

    ################################################################################
    def __init__(self,dirname,filename,exe):
        super().__init__(dirname,filename,exe)
        ctime=os.stat(self.path).st_mtime   #ファイル更新日
        self.date=datetime.fromtimestamp(ctime).date().strftime('%Y%m%d')    #文字列YYYYMMDD

        # 写真以外の場合、init時にはまだ型番を設定しない。全ファイルの抽出が終わってから、
        # move_toプロパティを呼び出す時に、同一ディレクトリのexif付きファイルからmodelを拝借する。

    ################################################################################
    #写真以外のファイルは、同一ディレクトリに存在する写真ファイルからモデルを取得
    @property
    def move_to(self):
        if self.dirname in targetdirs:
            self.model = targetdirs[self.dirname].model
        else:
            self.model = 'unknown'

        return super().move_to

################################################################################
#SPD(SILKYPIX)ファイル。
################################################################################
class SilkyPixFile(NotPhotoFile):
    #撮影日も、型番もinit時には不明なので、独自initはない。
    #全ファイルの収集が終わった後、親ディレクトリのexifから判断。

    @property
    def move_to(self):
        currentDirName = os.path.basename(self.dirname) #カレントフォルダ「SILKYPIX_DS」のハズ

        ptn = re.compile(r'\...\.spd$')                  #「～.xx.psd」で終わる部分を、
        photoFileName=ptn.sub('',self.basename)         #取り除く。
                                                        #親ディレクトリのパスを取得
        parentDirPath  = os.path.abspath( os.path.join(self.dirname, '..') )
                                                        #元の写真ファイルのフルパス
        photoFilePath = os.path.join(parentDirPath ,photoFileName)

        if photoFilePath in targetfiles:               #対象ファイル辞書に存在するなら、
            photoFile = targetfiles.get(photoFilePath) #元の写真ファイルをゲット
        else:
            print (photoFilePath)
            return 'unknown'

        return os.path.join(photoFile.move_to,currentDirName)


################################################################################
#どこに移動したらいいか判断できないファイル。
################################################################################
class NotSupportedFile(CameraFile):
    @property
    def move_to(self):
        return 'unknown'

################################################################################
def move_files(path):
    ret=[0,0,0]   #重複有無、不明有無、上書き検出

    with open(path) as f:
        reader=csv.reader(f)
        csv_list=list(reader)

    header = csv_list[0]
    del csv_list[0]



    ind_org_dir  = header.index('元パス')
    ind_filename = header.index('ファイル名')
    ind_move_to  = header.index('移動先')
    ind_dup_cnt  = header.index('移動先重複')
    copy_list=[]

    #############################
    #読み込み・詰め替え。移動元の存在確認。
    #############################
    newpath_list=[]
    newpath_dict={}
    for line in csv_list:
        org_dir  = line[ind_org_dir]
        filename = line[ind_filename]
        move_to  = line[ind_move_to]
        dup_cnt  = line[ind_dup_cnt]
        org_file = os.path.join(org_dir,filename)
        new_file = os.path.join(move_to,filename)

        newpath_list.append(new_file)   #新ファイル名を列挙
        newpath_dict[new_file]=0        #新ファイル名をキーに辞書追加

        copy_info={ 'org_file' : org_file  ,
                    'move_to'  : move_to   ,
                    'new_file' : new_file  }
        copy_list.append(copy_info)

        if not os.path.exists(org_file):    #移動元ファイルが存在しない場合。
            print(os.path.basename(path)+' に、存在しない移動元ファイル「'+org_file+'」が登録されています。')
            print('確認してください。')

        if 'unknown' in move_to:        #unknownは、事前に出力済みのcsvを確認するだけ。
            ret[1]=1

    #############################
    #移動先のチェック
    #############################
    move_flg=False
    for x in newpath_list:
        newpath_dict[x] += 1
        if newpath_dict[x] > 1:         #リスト内重複アリは、csvの値ではなく、再確認する。
            print('リスト内重複：',x)
            ret[0]=1
            break

        if os.path.exists(x):    #既存ファイル有無確認は、改めて存在確認を実施。
            if org_file != new_file:
                print('既存ファイルと重複：',x)
                ret[2]=1
                break
        else:
            move_flg = True             #移動を実行できる。


    if 1 in ret:
        return ret

    if move_flg == False:
        print('移動する必要のあるファイルは存在しませんでした。')
        return ret

    #############################
    #移動実行！！
    #############################
    print('')
    x=''
    while x!= 'MOVE!':
        print('【確認】')
        print('CSVファイル ('+ path +') に設定された内容に従って、')
        print('ファイルの移動を実施してもよい場合は、「MOVE!」と入力してください。')
        print('>',end='')
        try:
            x=input()
        except KeyboardInterrupt:
            x=''
        if x.lower() in ('','stop','quit','exit'):
            print('処理を中断しました。')
            return ret


    print('ファイル移動')
    pb=ProgressBar( maxval=len(csv_list) )
    i=0
    pb.start()

    for x in copy_list:
        pb.update(i)
        i+=1
        if x['org_file'] == x['new_file']:  #移動する必要が無いファイルの場合
            continue                        #スキップ

        if not os.path.isdir(x['move_to']): #移動先フォルダが存在しない場合は、
            os.makedirs(x['move_to'])       #フォルダを作成

        #print(x['org_file'],x['move_to'])
        shutil.move(x['org_file'],x['move_to'])

    pb.finish()

    return ret



################################################################################
def termination_message(mode,ret):
    if 1 in ret:
        print('')
    if ret[0]==1:
        print('整理先のフォルダで重複するファイルがあります。')
    if ret[1]==1:
        print('整理先のフォルダが不明なファイルがあります。')
    if ret[2]==1:
        print('整理先のフォルダに、既に同名のファイルが存在しています。')
    if 1 in ret:
        print(os.path.basename(copy_csv) + ' を確認してください。')

################################################################################
#メイン
################################################################################
targetfiles={}                      #対象ファイル辞書
targetdirs={}                       #ディレクトリ辞書（ディレクトリ内のexif持ちを一つ保持）

if len(sys.argv) == 3:
    mode=1                          #モード1：調査してCSVを出力する。
    photo_store_src=sys.argv[1]
    photo_store_dst=sys.argv[2]
    copy_csv = os.path.join(photo_store_src,'copy.csv')

    if not(os.path.isdir(photo_store_src) ):
        print(photo_store_src + ' は存在しません。存在するフォルダを指定してください。')
        sys.exit()

elif len(sys.argv) == 2:
    mode=2                          #モード2：CSVを元にファイルの移動を行う。
    copy_csv=sys.argv[1]

    if os.path.isdir(copy_csv):
        copy_csv = os.path.join(copy_csv,'copy.csv')

    if not(os.path.isfile(copy_csv)):
        print(copy_csv + ' は存在しません。')
        print('存在するファイル、または、copy.csv が格納されているフォルダを指定してください。')
        sys.exit()

else:
    print('使い方：python ' + sys.argv[0] + ' [対象フォルダ] [整理先フォルダ]')
    print('対象フォルダの直下に、copy.csv という名前で、整理先フォルダへの格納先の案を出力します。')
    print('整理先フォルダを確認して正しくない場合は修正して、正しければそのままで、')
    print('引数に copy.csv を指定して再度実行してください。')
    print('')
    print('使い方：python ' + sys.argv[0] + ' [対象フォルダ\copy.csv]')
    print('引数に指定された copy.csv（フルパス） の情報を元に、ファイルの移動を行います。')
    print('元に戻すことはできないので、確認を行ってから実行してください。')
    sys.exit()

if mode==1:
    file_collection(photo_store_src)    #対象ファイル収集
    ret = output_result()               #表示 or 出力
    termination_message(mode,ret)
    if not 1 in ret:                    #エラーが発生していなかったら、
        mode=2                          #そのままモード２へ移行。
    else:
        print('')
        print('copy.csvを確認・修正してから、処理を続行することができます。')
        print('処理を続行する場合は、yes を入力してください。')
        print('>', end='')
        try:
            x=input()
        except KeyboardInterrupt:
            x=''
        if len(x)==0 or  x[0].lower() != 'y':
            print('処理を終了します。')
            sys.exit()
        else:
            mode=2

if mode==2:
    ret = move_files(copy_csv)
    termination_message(mode,ret)
```
</details>

# 参考：piexif 以外に試してみたモジュール達

試してみたものを時系列に紹介します。

## Pillow 10.0.0
Canon の CR3 と、Sony の ARW が読み込めなかったので、使うのを止めました。
どちらかというと画像処理をさせる用途で使われることが多いようです。

インストールはこんな感じ。
```
pip install Pillow
```

撮影日付とメーカーとモデルを出力させるのは、こんな感じです。
```.py
from PIL import Image
def test_pillow(fullpath):
    img  = Image.open(fullpath)    #ファイルを開く。
    exif = img.getexif()           #Exifを取得。
    exif_dict=exif.get_ifd(0x8769) #Exif辞書(IFD:Image File Directory)を取得。

    print(datetime.strptime(exif_dict[36867], '%Y:%m:%d %H:%M:%S').date().strftime('%Y%m%d'))
    print(exif[271].rstrip())
    print(exif[272].rstrip())

test_piexif(r'c:\spam.jpg')
```
うん？.get()を使った方がよいのかしら。
まぁ、サンプルコードだから、もしキーが無くて落ちてもしょうがないね。

## ExifRead 3.0.0
こちらは ARW は読み込むことができました。（相変わらず、CR3はダメでしたが。）
CASIO の EX-ZR1100 の JPG を食わせると、「Possibly corrupted field XXXX in MakerNote IFD」というエラーを吐くことがあります。
例外は起こらず、やりたかったこと自体は実現できるのですが、たくさんのファイルを処理させた時に、画面がズンドコ流れてしまうので、違うモジュールを試すことにしました。

インストール
```
pip install ExifRead
```

撮影日付・メーカー・モデルを出力。
```.py
import exifread
def test_exifread(fullpath):
    with open(fullpath,'rb') as f:
      exif = exifread.process_file(f)

    print(datetime.strptime(exif['EXIF DateTimeOriginal'].printable,'%Y:%m:%d %H:%M:%S').date().strftime('%Y%m%d'))
    print(exif['Image Make'].printable)
    print(exif['Image Model'].printable)

test_piexif(r'c:\spam.jpg')
```

## pyexiv2 2.8.3
ちゃんと動作するためには「Microsoft Visual C++ 2015 再頒布可能パッケージ Update 3」のインストールが必要でした。
動くようになったと思ったら、パス名に日本語が含まれていると、encoding='cp932' を指定してもダメなこともあったりして、使うのを止めました。

インストール
```
pip install pyexiv2
```

撮影日付・メーカー・モデルを出力。
```.py
import pyexiv2
def test_pyexiv2(fullpath):
    with pyexiv2.Image(fullpath,encoding='cp932') as img:
        exif=img.read_exif()

    print(datetime.strptime(str(exif['Exif.Image.DateTime']), '%Y:%m:%d %H:%M:%S').date().strftime('%Y%m%d'))
    print(exif['Exif.Image.Make'])
    print(exif['Exif.Image.Model'])

test_piexif(r'c:\spam.jpg')
```


## rawpy 0.18.1
本来はRAW現像などで使うためのモノのようで、ここに並べること自体が場違いかもしれません。
Exif を取得するための使い方が分からなかったので、使うのを止めました。

# 余談：本課題に取り組むことになった経緯

なんでこんなことをやりだしたのかという経緯です。（日記のようなものです。）

実は、ちょっと前に、高級コンデジなるものを試しに買ってみたのでございます。
そうしたら、写真を撮るのが楽しくなって、コンデジの台数が増えたりしつつ、結構な枚数の写真を撮ったりしたんでございます。

そして、各コンデジにブッ刺したままだったSDカードがパンパンになってから、ハードディスクに取り込んだ時に気付いたんです。

「これ整理するのめんどくせー」と。


その日から、写真をPCのディスク上で管理するためのツール探しが始まりました。

## 簡単デジカメ写真整理
写真を撮りまくるようになる前から使っていたもので、日付毎のフォルダ階層を作ってくれます。でも、カメラ別にも分けてほしいんです。
スマホで撮ったやつと区別したいし、人からもらったファイルとかも、日付別なだけだと混ざっちゃいますし。
というわけで、都度都度これで整理されたものを元に、手動で別のフォルダ構成へ移動して使用していました。
複数のカメラで写真をたくさん撮るようになった後では、ちょっとメンドクサイので、ほかのツールへの乗り換えを考えました。

## 仕分けちゃん
というわけで、「仕分けちゃん」を使ってみました。
仕様的には、ほぼ理想的です。

ただ、たくさんのファイルを一気に処理させようとすると、ファイルを途中までバラ撒いた状態でフリーズしてしまうというのが難点でした。
しかもその後、ファイルが移動元と先の両方にダブって残ったり、消そうとしてもファイルが掴まれたままになって消せなくなったりと、大変だったので使うのをやめました。

## exiftool
次に行きついたのは、本記事でも subprocess で呼び出している大活躍の万能ツールです。
結局 CR3形式 から情報を取得できたのはこれだけでした。

実は、このツールだけで写真整理を行ってしまうこともできちゃうという代物です。
CUIですけれどもね。

SDカード満杯の百GBを超えるような大量ファイルを直接移動させるのは、失敗したときに怖い（仕分けちゃんトラウマ）というのがあり、積極的に使ってみませんでした。

## Rexifer
こんどはRexiferというものを見つけました。
使いこなせないくらいに高機能で、とても良いものっぽいのです。
リネーム・移動結果の事前確認までできちゃいます。（私はリネームはしないので、フォルダ移動だけですが）

ただ、フォルダ名に手を加えた後にも、継続してそのフォルダ名に対して仕分けができるようにしたい、というのは無理っぽそうでした。（もしかしたら、よく調べたらできたのかもしれませんが）

# 最後に

python関連の本を買って独習しようと思いつつ、何か良い題材はないかなー、・・・と思っていたところだったので、やってみました。
簡単です。色々と捗りそうです。

Excelとかも操作できるらしいので、業務でも使ってみたいものですが、まぁダメなんだろうなぁ。。

今回作成したツールを、実際に使用してみた感想は、[次回の記事](https://qiita.com/azuwai2/items/3a8ddf41f162bb4941e2#%E5%89%8D%E5%9B%9E%E3%81%BE%E3%81%A7%E3%81%AE%E3%81%8A%E8%A9%B1)参照なのです。