Pythonで写真や動画のメタデータを取得しよう!!

## はじめに
自分はカメラ (写真)が趣味なので撮影、編集や写真整理、写真のメタデータに興味があります。

Pythonで写真のメタデータを取得しようと思って調べると、pillowを使う記事が多いですが、pillowはRAW画像や動画に対応しないので、カメラで撮ったすべてのファイルに対応できません。

### なのでPythonで写真や動画のメタデータを取得するライブラリを作りました！

## インストール
`pip install photo-metadata`

## メタデータを取得

幅広い形式に対応するため、バックエンドに`exiftool`を使いました。
なのでexiftoolをインストールする必要があります。
[exiftool](https://exiftool.org/)
pythonで写真のメタデータを取得する際はexiftoolが一番だと思っています。

### Metadataクラス

`Metadata`クラスは、メタデータ操作の中心となるクラスです。
```
from photo_metadata import Metadata
```

#### 初期化
```
metadata = Metadata(file_path="path/to/your/image.jpg", exiftool_path="path/to/exiftool.exe", japanese=True)
```
- `file_path` (str): 画像ファイルのパス
- `exiftool_path` (str, optional): ExifTool実行ファイルのパス。デフォルトは`exiftool`
- `japanese` (bool, optional): デフォルトは`True`


```
def __init__(self, file_path: str, exiftool_path: str = default_exiftool_path, japanese: bool = True):
        
        if not os.path.isfile(default_jp_tags_json_path):
            if japanese:
                raise FileNotFoundError(f"JP tags jsonファイルが見つかりません: {default_jp_tags_json_path}")
            else:
                raise FileNotFoundError(f"JP tags json file not found: {default_jp_tags_json_path}")
        
        if not isinstance(file_path, (str, Path)):
            if japanese:
                raise TypeError("file_pathはstrまたはPath型を指定してください。")
            else:
                raise TypeError("file_path must be str or Path.")
        
        if not isinstance(exiftool_path, (str, Path)):    
            if japanese:
                raise TypeError("exiftool_pathはstrまたはPath型を指定してください。")
            else:
                raise TypeError("exiftool_path must be str or Path.")
        
        
        


        self.file_path = file_path
        self.exiftool_path = exiftool_path
        self.jp_tags_json_path = default_jp_tags_json_path
        self.metadata: dict = None
        self.key_map = None
        self.reverse_key_map = None
        self.japanese_metadata: dict = None
        self.japanese = japanese


        
        if self.japanese:
            self.error_string = "エラー"
        else:
            self.error_string = "error"
        
        if not os.path.isfile(self.file_path):
            if self.japanese:
                raise FileNotFoundError(f"ファイルが見つかりません: {self.file_path}")
            else:
                raise FileNotFoundError(f"file not found: {self.file_path}")
        
        try:
            command_exiftool_text = f'{self.exiftool_path} -G -json "{self.file_path}"'
            
            result = subprocess.run(command_exiftool_text, capture_output=True, text=True, shell=True, creationflags=subprocess.CREATE_NO_WINDOW, encoding='utf-8', check=True)
            self.metadata = json.loads(result.stdout)[0]
            self.metadata["SourceFile"] = file_path
            self.japanese_tags_by_keymap_dict()

            

            tags = ["file_path", "file_name", "shooting_date", "model_name", "lens_name", "focal_length", "dimensions", "file_size", "gps", "gps_to_google_maps"]
            ja_tags = ["ファイルパス", "ファイル名", "撮影日時", "機種名", "レンズ名", "焦点距離", "寸法", "ファイルサイズ", "gps", "gps情報のgoogleマップのurl"]
            funcs = [(lambda: self.file_path), (lambda: os.path.basename(self.file_path)), self.get_date, self.get_model_name, self.get_lens_name, self.get_focal_length, self.get_image_dimensions, self.get_file_size,
                     self.get_gps_coordinates, self.export_gps_to_google_maps]
            for tag, ja_tag, func in zip(tags, ja_tags, funcs):
                data = func()
                
                self.metadata[f"original_of_this_lib:{tag}"] = data
                self.japanese_metadata[f"original_of_this_lib:{ja_tag}"] = data

            
            
        except Exception as e:
            if self.japanese:
                print("\n-----------エラー-----------")
                print(f"ファイル: {self.file_path}")
                #print(f"標準エラー出力: {result.stderr}")
                print(f"例外: {e}")

                self.metadata = None

                print("メタデータを取得できません")
                print("----------------------------")
            else:
                print("\n-----------error-----------")
                print(f"file: {self.file_path}")
                #print(f"stderr : {result.stderr}")
                print(f"except : {e}")

                self.metadata = None

                print("Failed to get metadata")
                print("----------------------------")
```

subprocessでコマンド実行しています。
出力をjson形式にして、`json.loads()`でdict型にできるように、`-json`オプションを付けています。このオプションを付けると、exiftoolの出力がjson形式の文字列になります。

#### メタデータの取得

メタデータは、辞書のようにアクセスできます。

英語のタグでアクセス
```
date_time = metadata["EXIF:DateTimeOriginal"]
print(date_time)
```

日本語のタグでアクセス
```
date_time_jp = metadata["EXIF:撮影日時"]
print(date_time_jp)
```

#### メタデータの変更

メタデータは、辞書のように変更できます。
```
metadata["EXIF:DateTimeOriginal"] = "2024:02:17 12:34:56"
```
変更をファイルに書き込む
```
metadata.write_metadata_to_file()
```
### write_metadata_to_fileメソッド
```
def write_metadata_to_file(self, file_path: str = None):
        if file_path is None:
            file_path = self.file_path

        print(file_path)

        write_metadata = {}

        for k, v in self.metadata.items():
            if k.split(":")[0] != "original_of_this_lib":
                write_metadata[k] = v
        
        write_metadata["SourceFile"] = "*"

        

        # メタデータをJSONファイルに一時的に保存
        temp_json = tempfile.NamedTemporaryFile(delete=False, suffix='.json')
        temp_json.close()
        print(temp_json.name)
        with open(temp_json.name, 'w', encoding='utf-8') as f:
            json.dump(write_metadata, f, ensure_ascii=False, indent=4)

        try:
            # exiftoolを使用してメタデータを書き込む
            command = f'{self.exiftool_path} -json="{temp_json.name}" -overwrite_original "{file_path}"'
            result = subprocess.run(command, capture_output=True, text=True, shell=True, creationflags=subprocess.CREATE_NO_WINDOW, check=True)
            
            if self.japanese:
                print(f"exiftoolの標準出力: {result.stdout}")
                print(f"exiftoolの標準エラー: {result.stderr}")
            else:
                print(f"exiftool standard output: {result.stdout}")
                print(f"exiftool standard error: {result.stderr}")

            if result.returncode != 0:
                if self.japanese:
                    raise RuntimeError(f"メタデータの書き込みに失敗しました。エラー: {result.stderr}")
                else:
                    raise RuntimeError(f"Failed to write metadata. Error: {result.stderr}")

            if self.japanese:
                print(f"メタデータが正常に書き込まれました: {file_path}")
            else:
                print(f"Metadata successfully written to: {file_path}")

        finally:
            # 一時ファイルを削除
            os.unlink(temp_json.name)
```

コマンドで`-json=`でパスを渡すと、jsonファイルに書いてあるメタデータをファイルに書き込んでくれます。
`-overwrite_original`オプションを使用すると、元のファイルのメタデータを上書きします。

#### メタデータの削除

メタデータは、`del`ステートメントで削除できます。
```
del metadata["EXIF:DateTimeOriginal"]
```

#### 比較

`==`と`!=`演算子を使用して、2つの`Metadata`オブジェクトを比較できます。
```
metadata1 = Metadata("image1.jpg")
metadata2 = Metadata("image2.jpg")

if metadata1 == metadata2:
    print("メタデータは同じです")
else:
    print("メタデータは異なります")
```

別のファイルで比較するとだいたいFalseになります。
メタデータには詳細な情報がたくさん入っているのがわかります。

#### その他のメソッド

- `get_date(format: str = '%Y:%m:%d %H:%M:%S')`: 撮影日時を取得 (日付フォーマットを指定できます)
- `get_model_name()`: カメラの機種名を取得
- `get_lens_name()`: レンズ名を取得
- `get_focal_length()`: 焦点距離を取得
- `get_image_dimensions()`: 画像の寸法を取得
- `get_file_size()`: ファイルサイズを取得
- `get_gps_coordinates()`: GPS座標を取得
- `export_gps_to_google_maps()`: GPS情報をGoogleマップのURLに変換
- `write_metadata_to_file(file_path: str = None)`: メタデータをファイルに書き込む
- `validate_metadata(match_all: bool, *conditions: tuple[str, str | int | float, str]) -> bool:`: メタデータを検証できます
- `show()`: ファイルを表示します
- `get_metadata_obj_dict(cls, file_path_list: list[str], exiftool_path: str = default_exiftool_path, progress_func: Callable[[int], None] = None, japanese: bool = True, max_workers: int = 40) -> dict[str, "Metadata"]`: 複数のファイルのメタデータを並列処理で高速に取得します。

exiftool_path引数のデフォルトは"exiftool"です。

### get_dateメソッド
撮影日時を取得します。

```
def get_date(self, format: str = '%Y:%m:%d %H:%M:%S', default_time_zone: str = '+09:00'):
        if "EXIF:DateTimeOriginal" in self.metadata:
            date = self.metadata["EXIF:DateTimeOriginal"]
            date = datetime.datetime.strptime(date, '%Y:%m:%d %H:%M:%S').strftime(format)
        elif "QuickTime:CreateDate" in self.metadata:
            if self.metadata["QuickTime:CreateDate"] == "0000:00:00 00:00:00":
                return self.error_string
            if "QuickTime:TimeZone" in self.metadata:
                dt = datetime.datetime.strptime(self.metadata["QuickTime:CreateDate"], '%Y:%m:%d %H:%M:%S')
                tz = datetime.datetime.strptime(self.metadata["QuickTime:TimeZone"].replace("+", ""), "%H:%M")
                tz = datetime.timedelta(hours=int(tz.strftime("%H")), minutes=int(tz.strftime("%M")))
                date = dt + tz
                date = date.strftime(format)
            else:
                dt = datetime.datetime.strptime(self.metadata["QuickTime:CreateDate"], '%Y:%m:%d %H:%M:%S')
                tz = datetime.datetime.strptime(default_time_zone.replace("+", ""), "%H:%M")
                tz = datetime.timedelta(hours=int(tz.strftime("%H")), minutes=int(tz.strftime("%M")))
                date = dt + tz
                date = date.strftime(format)
        else:
            date = self.error_string
        return date

```
datetime.strptime、.strftimeを使って日付のフォーマットを指定できるようにしました。
動画にも対応できるように、"QuickTime:CreateDate"で撮影日時を取得します。ただ、"QuickTime:CreateDate"はUTCの時間なので、"QuickTime:TimeZone"の時間をプラスしています。　
"QuickTime:CreateDate"があって、"QuickTime:TimeZone"が無い場合にデフォルトのタイムゾーンを指定できます。

```
file_path = "ファイルパス"
md = Metadata(file_path)
print(md.get_date('%Y年%m月%d日-%H.%M.%S'))
```

### get_metadata_obj_dictメソッド
複数のファイルのメタデータを高速に取得します。

```
@classmethod
    def get_metadata_obj_dict(cls, file_path_list: list[str], exiftool_path: str = default_exiftool_path, progress_func: Callable[[int], None] = None, japanese: bool = True, max_workers: int = 40) -> dict[str, "Metadata"]:
        
        def get_metadata_obj(file_path: str):
            return file_path, Metadata(file_path, exiftool_path, japanese=japanese)
        
        file_list_len = len(file_path_list)
        metadata_obj_dict = {}
        # 並列処理のためのExecutorを作成
        with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
            
            # ファイルパスのリストをsubmitメソッドに渡し、Futureオブジェクトのリストを作成
            futures_text = [executor.submit(get_metadata_obj, file_path) for file_path in file_path_list]

            progress = 0

            for future in tqdm(concurrent.futures.as_completed(futures_text), dynamic_ncols=True, total=file_list_len):
            # 処理結果を取得
                result_file, result_md = future.result()
                metadata_obj_dict[result_file] = result_md

                progress += 1

                if not progress_func is None:
                    progress_func((progress * 100) // file_list_len)
        if not progress_func is None:
            progress_func(100)

        return metadata_obj_dict
```

tqdmでプログレスバーを表示します。
concurrent.futures.as_completedはFutureオブジェクトのリストで終わった順にイテレーターを返すので、このコードでは終わった順にforが回ります。

## おわりに
写真や動画のメタデータを取得に関するpythonライブラリを作ってみました。
自分では比較的簡単に、便利なライブラリが作れたと思っています。
exiftoolやsubprocessの使い方や、メタデータについて知れました。
コードは[GitHub](https://github.com/kingyo1205/photo-metadata)で見れます。
Windows 11, Python 3.10.11で動作を確認しています。
Pythonで写真や動画のメタデータを取得したい方の参考になれば幸いです。