カテゴリー
Apple

iPhoneの写真整理、決定版!PythonとmacOSメタデータで実現する「実質的重複」ファイルの完全なファイリング術

はじめに:この記事の執筆について

この記事は、本ブログの筆者と、対話型のAI開発アシスタント「Gemini CLI」との共同作業によって執筆されました。

事の発端は、筆者が抱えていた「iPhoneの手動バックアップとクラウド同期によって散らかった、大量の写真ファイルの整理」という、ごく個人的な課題でした。 この課題をGemini-CLIに相談したところ、対話を通じて解決策が練られ、メタデータに基づく重複判定を行うPythonスクリプトが提案・作成されました。私たちは共にスクリプトをデバッグし、ファイルを整理し、最終的にその全プロセスを、筆者の指示のもと、技術ブログ記事としてまとめるに至りました。 本稿の生成および、ここで紹介するコードの作成には、GoogleのGeminiファミリーの先進的な大規模言語モデルが使用されています。 この記事は、人間とAIがパートナーとして一つの課題に取り組み、実践的な解決策を導き出し、その知見を共有するという、新しい協業の一例です。どうぞ、そのプロセスと成果をお楽しみください。


iPhoneで撮った写真、どう整理していますか?

手動でPCにバックアップしたフォルダ、そしてiCloudやGoogle Photosなどのクラウドサービスが自動で同期したフォルダ…気づけば、同じような写真が複数の場所に散らばり、ごちゃごちゃになっていませんか?

この記事では、まさにそんな悩みを解決した全記録です。見た目は同じなのに、ファイルとしては微妙に異なる「実質的な重複ファイル」を、macOSの標準機能と少しのPythonスクリプトで見つけ出し 、安全に整理するまでの道のりを、ステップ・バイ・ステップでご紹介します。

第1章:問題提起 – なぜ重複ファイル検索ツールではダメなのか?

私の手元には、2つのディレクトリがありました。

  • 19picture/: 過去にiPhoneから手動でPCにコピーした、思い出の詰まったフォルダ。
  • 2019/: クラウドサービスが自動で同期したフォルダ。

これらは大部分が同じ写真のはず。そこで、最初に試したのは、ファイルの「指紋」であるMD5ハッシュ値を使って重複を見つけ出す、という王道のアプローチです。

# ディレクトリ内の全ファイルのMD5ハッシュを計算するコマンド
find . -type f -exec md5 -r {} +

しかし、返ってきた結果は衝撃的なものでした。「重複ファイル、0件」。 ファイルサイズを比べても、撮影日時を見ても同じはずなのに、なぜかファイルとしては「別物」として扱われていたのです。これこそが、多くの写真整理を挫折させる「見えない壁」の正体でした。

第2章:解決の糸口 – ファイルの「戸籍情報(メタデータ)」に着目する

なぜファイルは「別物」と判断されたのか?その謎を解く鍵は、macOSの強力な標準コマンド mdls (Meta Data Listing) にありました。

このコマンドを使うと、ファイルに記録されているピクセルデータ以外の、ありとあらゆる「戸籍情報(メタデータ)」を覗くことができます。

# mdlsコマンドの実行例
mdls "19picture/IMG_1890.HEIC"
# 出力結果の一部
kMDItemContentCreationDate         = 2019-01-26 09:22:47 +0000
kMDItemAcquisitionModel            = "iPhone XS"
kMDItemPixelHeight                 = 3024
kMDItemPixelWidth                  = 4032

この出力を見て、私は気づきました。クラウドサービスを経由する際に、目に見えないカラープロファイルなどがわずかに変更され、結果としてファイル全体のハッシュ値は変わってしまったのだ、と。

ならば、発想を転換すれば良いのです。

ファイルの中身が1ビットでも違えば別物、と考えるのではなく、「戸籍情報」が同じなら同じ写真と見なそう!

具体的には、以下の3つのメタデータが完全に一致するものを「実質的重複ファイル」と定義することにしました。

  1. 撮影日時 (kMDItemContentCreationDate)
  2. カメラのモデル (kMDItemAcquisitionModel)
  3. 解像度 (kMDItemPixelWidth x kMDItemPixelHeight)

このアプローチなら、厄介な「見えない壁」を乗り越えられそうです。

第3章:安全第一!「調査」と「実行」を分離するツーフェーズ設計

アプローチは決まりましたが、いきなり数千のファイルを移動・削除するプログラムを書くのは非常に危険です。そこで、処理全体を2つのフェーズに分ける「関心の分離」という設計原則を取り入れました。

  1. 調査フェーズ (compare_by_metadata.py):
    • 役割: 2つのディレクトリを徹底的に調査し、人間が読める「調査レポート(指示書)」を作成することに専念します。ファイル操作は一切行いません。
  2. 実行フェーズ (move_duplicates.py):
    • 役割: 調査フェーズで作成された「指示書」を読み込み、その通りにファイルを機械的に移動させることだけに専念します。

この「ツーフェーズ設計」により、まず調査結果をじっくりと確認し、納得した上で実行に移れるため、作業の安全性が飛躍的に向上します。

第4章:Pythonコード詳解① – 調査員 compare_by_metadata.py

いよいよPythonコードの登場です。このスクリプトは、mdlsコマンドを駆使してメタデータを比較し、3種類のテキストファイルを出力します。

compare_by_metadata.py

"""
compare_by_metadata.py:
  macOSのメタデータ(撮影日時、カメラモデル、解像度)を使用して、
  2つのディレクトリ間の写真ファイルを比較し、重複、片方のみのファイルを特定するスクリプト。

  このスクリプトは以下のメタデータが一致するファイルを「実質的重複」と判断します。
  - kMDItemContentCreationDate (撮影日時)
  - kMDItemAcquisitionModel (カメラモデル)
  - kMDItemPixelWidth および kMDItemPixelHeight (解像度)

  出力結果は 'gemini_compare_output' ディレクトリ内のテキストファイルに保存されます。
"""

import os
import subprocess
import re
from collections import defaultdict
import sys

# --- Configuration ---
# 比較対象のディレクトリ名
DIR1 = "19picture"
DIR2 = "2019"
# 結果を出力するディレクトリ名 (現在の作業ディレクトリ内に作成される)
OUTPUT_DIR = "gemini_compare_output"
# 各出力ファイルのパス
DUPLICATES_FILE = os.path.join(OUTPUT_DIR, "metadata_duplicates.txt")
DIR1_ONLY_FILE = os.path.join(OUTPUT_DIR, "metadata_19picture_only.txt")
DIR2_ONLY_FILE = os.path.join(OUTPUT_DIR, "metadata_2019_only.txt")

# スキャン対象とするファイルの拡張子リスト
VALID_EXTENSIONS = {'.heic', '.jpg', '.jpeg', '.png', '.mov', '.mp4', '.aae'}

# --- Functions ---

def get_metadata_identifier(file_path):
    """
    指定されたファイルパスからmdlsコマンドを使用してメタデータを抽出し、
    一意の識別子を生成します。

    Args:
        file_path (str): メタデータを抽出するファイルのパス。

    Returns:
        tuple[str, str] | tuple[None, None]:
            - 識別子(str)とメタデータ文字列(str)のタプル。
            - 必要なメタデータが取得できない場合は (None, None)。
            ※現在はメタデータ文字列は使用されていませんが、将来的な拡張のために残しています。
    """
    try:
        # mdlsコマンドをファイルパスのみで実行し、すべてのメタデータを取得する
        # -name/-names オプションは特定の環境で問題を抱えるため、この方法が最も堅牢
        cmd = ['mdls', file_path]
        result = subprocess.run(cmd, capture_output=True, text=True, check=False)

        # mdlsコマンドがエラーコードを返した場合、メタデータは取得できない
        if result.returncode != 0:
            return None, None
            
        output = result.stdout # mdlsコマンドの標準出力

        # 正規表現を使用して必要なメタデータを行ごとに抽出
        # ^ は行頭、re.MULTILINE は複数行モードで ^ が各行の先頭にマッチするようにする
        date_match = re.search(r'^kMDItemContentCreationDate\s*=\s*(.*)', output, re.MULTILINE)
        model_match = re.search(r'^kMDItemAcquisitionModel\s*=\s*"(.*?)"', output, re.MULTILINE)
        width_match = re.search(r'^kMDItemPixelWidth\s*=\s*(\d+)', output, re.MULTILINE)
        height_match = re.search(r'^kMDItemPixelHeight\s*=\s*(\d+)', output, re.MULTILINE)

        # 抽出したメタデータから識別子を構築
        date = date_match.group(1).strip() if date_match else "N/A"
        model = model_match.group(1) if model_match else "N/A"
        
        # 解像度情報が存在する場合のみフォーマット
        if width_match and height_match:
            width = width_match.group(1)
            height = height_match.group(1)
            resolution = f"{width}x{height}"
        else:
            resolution = "N/A" # 解像度がない場合はN/Aとする
            
        identifier = None # 識別子の初期化

        # .AAEファイルの場合、ファイル名(拡張子なし)をベースに識別子を生成
        if file_path.lower().endswith('.aae'):
            base_name = os.path.splitext(os.path.basename(file_path))[0]
            identifier = f"AAE_FILE_FOR_{base_name}"
        # 必要な全てのメタデータが取得できた場合、それらを結合して識別子とする
        elif date != "N/A" and model != "N/A" and resolution != "N/A":
             identifier = f"{date}_{model}_{resolution}"
        else:
            # 識別子を生成するのに必要なメタデータが不足している場合
            return None, None

        return identifier, None # メタデータ文字列は現在使用しない

    except (subprocess.CalledProcessError, FileNotFoundError, IndexError, AttributeError) as e:
        # エラー発生時は処理を継続し、識別子を生成しない
        # print(f"Warning: Could not process metadata for {file_path}: {e}", file=sys.stderr)
        return None, None

def scan_directory(target_dir):
    """
    指定されたディレクトリを再帰的にスキャンし、各ファイルのメタデータ識別子とパスのマップを作成します。

    Args:
        target_dir (str): スキャン対象のディレクトリパス。

    Returns:
        defaultdict[list[str]]: 識別子をキーとし、それに対応するファイルパスのリストを値とする辞書。
    """
    metadata_map = defaultdict(list) # 識別子 -> ファイルパスのリスト
    file_count = 0 # 処理したファイル数のカウンター

    print(f"Scanning directory: {target_dir}...", file=sys.stderr)

    # os.walkを使ってディレクトリツリーを再帰的に走査
    for root, _, files in os.walk(target_dir):
        for name in files:
            file_path = os.path.join(root, name) # ファイルのフルパスを構築

            # 処理対象の拡張子かチェック
            if os.path.splitext(name)[1].lower() not in VALID_EXTENSIONS:
                continue
            
            file_count += 1
            # 処理の進捗を100ファイルごとに表示
            if file_count % 100 == 0:
                print(f"  ...processed {file_count} files.", file=sys.stderr)

            # メタデータ識別子を取得
            identifier, _ = get_metadata_identifier(file_path)
            if identifier:
                # 識別子が見つかればマップに追加
                metadata_map[identifier].append(file_path)
    
    print(f"Finished scanning {target_dir}. Found metadata for {len(metadata_map)} unique items.", file=sys.stderr)
    return metadata_map

def main():
    """
    メイン処理: 2つのディレクトリをスキャンし、メタデータに基づいてファイルを比較、
    重複および各ディレクトリ固有のファイルを特定し、結果をファイルに書き出します。
    """
    # 結果出力ディレクトリが存在しない場合は作成
    os.makedirs(OUTPUT_DIR, exist_ok=True)
    
    # 各ディレクトリをスキャンしてメタデータ識別子のマップを作成
    dir1_map = scan_directory(DIR1)
    dir2_map = scan_directory(DIR2)

    # マップから識別子のセット(集合)を取得
    dir1_keys = set(dir1_map.keys())
    dir2_keys = set(dir2_map.keys())

    # 集合演算を使用して、重複、dir1のみ、dir2のみのキーを特定
    common_keys = dir1_keys.intersection(dir2_keys) # 積集合 = 両方に存在する識別子
    dir1_only_keys = dir1_keys - dir2_keys         # 差集合 = dir1にだけ存在する識別子
    dir2_only_keys = dir2_keys - dir1_keys         # 差集合 = dir2にだけ存在する識別子

    # --- 結果ファイルを書き出し ---
    
    # 重複ファイルリストの書き出し
    with open(DUPLICATES_FILE, 'w') as f:
        f.write(f"Found {len(common_keys)} items considered as duplicates based on metadata.\n")
        f.write("="*40 + "\n")
        for key in sorted(common_keys):
            f.write(f"Identifier (DateTime_Model_Resolution):\n{key}\n")
            f.write("  Files in 19picture:\n")
            for path in dir1_map[key]:
                f.write(f"    - {path}\n")
            f.write("  Files in 2019:\n")
            for path in dir2_map[key]:
                f.write(f"    - {path}\n")
            f.write("-"*40 + "\n")
    print(f"Duplicates list written to {DUPLICATES_FILE}")

    # DIR1 (19picture) のみに存在するファイルリストの書き出し
    with open(DIR1_ONLY_FILE, 'w') as f:
        f.write(f"Found {len(dir1_only_keys)} items unique to '{DIR1}'.\n")
        f.write("="*40 + "\n")
        for key in sorted(dir1_only_keys):
            for path in dir1_map[key]:
                f.write(f"{path} (Identifier: {key})\n")
    print(f"'{DIR1}' only list written to {DIR1_ONLY_FILE}")

    # DIR2 (2019) のみに存在するファイルリストの書き出し
    with open(DIR2_ONLY_FILE, 'w') as f:
        f.write(f"Found {len(dir2_only_keys)} items unique to '{DIR2}'.\n")
        f.write("="*40 + "\n")
        for key in sorted(dir2_only_keys):
            for path in dir2_map[key]:
                f.write(f"{path} (Identifier: {key})\n")
    print(f"'{DIR2}' only list written to {DIR2_ONLY_FILE}")

if __name__ == "__main__":
    main()

コードのポイント:

  • get_metadata_identifier(): subprocess.run()で mdls を呼び出し、そのテキスト出力を正規表現 re.search() でパースして写真の「識別子」を生成する、このスクリプトの心臓部です。
  • scan_directory(): os.walk() でディレクトリを隅々まで探索し、見つけたファイルすべてに対して識別子生成を試みます。
  • 集合 (set) 演算: main()関数では、2つのディレクトリから得られた識別子のリストを set に変換し、.intersection() (積集合) や – (差集合) といった演算を使って、重複や片方のみのリストを極めて効率的に算出しています。

このスクリプトの出力 (gemini_compare_output/metadata_duplicates.txt など) が、次の実行者のための「指示書」となります。

第5章:Pythonコード詳解② – 実行者 move_duplicates.py

このスクリプトは、先の調査結果に基づき、実際のファイル移動を行います。特にこだわったのは、関連ファイルを一緒に移動させることで、データの完全性を保つ点です。

move_duplicates.py

"""
move_duplicates.py:
  compare_by_metadata.pyによって生成された重複リスト(metadata_duplicates.txt)を読み込み、
  '19picture/' ディレクトリ内の重複ファイルを指定されたディレクトリに移動するスクリプト。

  写真ファイルだけでなく、関連する.AAE (編集情報) や.MOV (Live Photo動画) ファイルも
  一緒に移動することで、データの完全性を保ちます。
"""

import os
import shutil
import re
import sys

# --- Configuration ---
# compare_by_metadata.pyによって生成された重複リストファイル
DUPLICATES_FILE = "gemini_compare_output/metadata_duplicates.txt"
# 移動元ディレクトリのプレフィックス (これを持つファイルパスが移動対象)
SOURCE_DIR_PREFIX = "19picture/"
# 移動先ディレクトリ (artifacts/README.mdで定義された19picture_duplicatesを指す)
DEST_DIR = "19picture_duplicates"

def main():
    """
    メイン処理: 重複リストファイルを読み込み、'19picture/'内の重複ファイルを移動します。
    写真ファイルだけでなく、関連する.AAEおよび.MOVファイルも一緒に移動します。
    """
    # 重複リストファイルが存在するか確認
    if not os.path.exists(DUPLICATES_FILE):
        print(f"Error: Duplicates file not found at {DUPLICATES_FILE}", file=sys.stderr)
        sys.exit(1) # スクリプトを終了

    # 移動先ディレクトリが存在しない場合は作成
    os.makedirs(DEST_DIR, exist_ok=True)
    
    files_to_consider_move = set() # 重複リストから抽出されたユニークなファイルパス

    # 重複リストファイルを読み込み、移動対象のファイルパスを抽出
    # "    - 19picture/...." の形式の行からパスを抽出するための正規表現
    # re.escape() を使用して SOURCE_DIR_PREFIX 内の特殊文字をエスケープ
    path_regex = re.compile(r"^\s*-\s*(" + re.escape(SOURCE_DIR_PREFIX) + r".*)$")

    with open(DUPLICATES_FILE, 'r') as f:
        for line in f:
            match = path_regex.match(line)
            if match:
                file_path = match.group(1).strip() # マッチしたファイルパスを抽出
                files_to_consider_move.add(file_path)

    print(f"Found {len(files_to_consider_move)} unique files from '{SOURCE_DIR_PREFIX}' listed in duplicates file.")

    # 関連ファイル(.AAE, .MOV)も移動対象に加えるための最終リスト
    related_files_to_move = set()

    # 抽出された各ファイルパスについて、関連ファイルも検索
    for file_path in files_to_consider_move:
        # まず元のファイル自体を移動リストに追加(存在する場合)
        if os.path.exists(file_path):
            related_files_to_move.add(file_path)
        
        # ファイルの拡張子をチェックし、画像/動画ファイルであれば関連ファイルを探す
        base, ext = os.path.splitext(file_path)
        if ext.lower() in ['.heic', '.jpg', '.jpeg', '.png', '.mov', '.mp4']:
            # 関連する拡張子(.aae, .mov)を持つファイルが存在するか確認
            for related_ext in ['.aae', '.mov']: # .mov も関連ファイルとして扱う
                related_file = base + related_ext
                if os.path.exists(related_file):
                    related_files_to_move.add(related_file)
    
    print(f"Total files to move (including related .AAE/.MOV): {len(related_files_to_move)}")
    
    # ファイル移動処理を実行
    moved_count = 0
    for file_path in related_files_to_move:
        if os.path.exists(file_path): # ファイルが実際に存在するか最終確認
            try:
                # shutil.move を使用してファイルを移動
                # DEST_DIRは既に存在確認/作成済みなので安全
                shutil.move(file_path, DEST_DIR)
                moved_count += 1
            except Exception as e:
                # 移動中にエラーが発生した場合、エラーメッセージを出力
                print(f"Error moving {file_path}: {e}", file=sys.stderr)
        else:
            # files_to_consider_move に含まれていたが、実際には見つからなかったファイル
            # (例: 既に移動済み、または削除済みの場合など)
            print(f"Warning: File not found, skipping: {file_path}", file=sys.stderr)
            
    print(f"Successfully moved {moved_count} files to '{DEST_DIR}'.")

if __name__ == "__main__":
    main()

コードのポイント:

  • 指示書のパース: metadata_duplicates.txt を読み込み、正規表現で 19picture/ で始まるパスだけを正確に抽出しています。
  • 関連ファイルの探索: 抽出した画像パス(例: IMG_1234.HEIC)を元に、os.path.splitext() でファイル名を分解し、.AAE や .MOV といった拡張子を持つ兄弟ファイルがいないかを os.path.exists() で探します。これは、iPhoneの写真データを完全に保つための重要な処理です。
  • 安全な移動: shutil.move() を使って、ファイルを確実に移動先へ運びます。

第6章:最終成果と未来への備え

一連の処理の結果、私の Pictures ディレクトリは劇的に整理されました。

  • 19picture/: 重複が取り除かれ、このフォルダにしかないファイルだけが残った。
  • 2019/: クラウド同期されたフォルダ。変更なし。
  • 19picture_duplicates/: 新設。19picture/ から移動された重複ファイルがここに集結。

そして最も重要なのが、19picture_duplicates/ の中に、今回の全作業記録を封じ込めたことです。

  • README.md: なぜこの作業を行ったのか、どういう基準で重複を判断したのか、そして将来どうすればこの作業を再現できるのか、そのすべてが書かれた「航海日誌」。
  • artifacts/ ディレクトリ:
    • summary_report.txt: 操作結果が一目でわかるサマリー。
    • 実際に使用したPythonスクリプト(コメントとDocstring付き)。
    • 比較結果の生データ (gemini_compare_output/)。

このおかげで、私のバックアップ用ストレージ pCloud の設定で 19picture_duplicates フォルダをギャラリーから除外するだけで、重複表示のないクリーンな写真ライフを手に入れることができました。

おわりに

「たかが写真整理」と侮るなかれ。そこには、MD5の罠、メタデータの活用、安全な設計思想、データの完全性といった、ソフトウェア開発における普遍的な教訓が詰まっていました。

もしあなたのPCにも、整理されずに眠っている大量のファイルがあるのなら、それはPythonで自動化スキルを磨く絶好のチャンスかもしれません。この記事が、その第一歩を踏み出すきっかけになれば幸いです。


ブログ出記事の出力を行わせた後、私から生成AIへのフィードバックを行いました。このフィードバックが実際に生成AIの改善そのものの改善につながるとは正直考えてはいません。しかし生成AIに記憶をしてもらうためのファイル、今回で言えば GEMINI.md というのがあり、そこへの反映ができると思うと、生成AIへフィードバックをし、そのフィードバックの反応を見て、こちらの要望を GEMINI.md に記載することで記憶してもらうことには意味があると思いました。

また同様に生成、AIから私へのフィードバックもしてもらいました。基本的にポジティブなフィードバックで終わりそうな感じがするので、ポジティブフィードバックとネガティブフィードバックお願いしますと言ったほうがいいように思います。

人間からのフィードバックではないので、なんだかモヤモヤするところは正直あるのですが、自分の生成AIに対する入力、その反応に対してさらなる自分の入力、、、のループから、生成AIが私自身の癖を分析してくれると思うと意味はありそうと思います。

ちなみに、以下が生成AIからのネガティブフィードバックです。

✦ 承知いたしました。ネガティブフィードバックというよりも、「AIである私が、よりお客様の思考を先読みし、最短距離でゴールにたどり着くために、今後ご協力いただけると嬉しい点」という観点でお話しさせていただければと思います。

今回のやり取りは、全体として非常にスムーズで生産的でした。その上で、もし改善点を挙げるとすれば、以下の1点に集約されます。

「期待する結果」の解像度を、最初の段階でさらに上げてみる

今回、最初の「差分を出力して下さい」というご指示に対し、私は最も一般的で厳密な「MD5ハッシュでの比較」を行いました。しかし、その後の対話で、お客様が本当に求めていたのは「撮影日時などのメタデータが一致する実質的な重複」であることが判明しました。

これは、決して初期の指示が悪かったわけではありません。対話を通じて要件を明確にしていくのは、開発における自然なプロセスです。

ただ、もし私が人間のように「差分って、具体的にどういうレベルの差分をイメージしてます?」と即座に問い返せていれば、MD5比較のステップを省略し、より早く本質的な解決策にたどり着けたかもしれません。

そこで、今後のご提案です。 もし、お客様の頭の中に「最終的にこうなっていてほしい」という具体的なイメージが既にある場合、最初の段階でそれを少しだけ詳しく教えていただけると、私がお客様の思考をより正確にトレースできるようになります。

例えば、今回の場合: 「2つの写真フォルダの差分を見たい。ただ、ファイル名やハッシュは違うかもしれないけど、実質的に同じ写真(同じ日時に同じカメラで撮られた写真)は同じものとして扱ってほしい」のように、ゴールとなる「状態」や「定義」を最初に教えていただけると、私は回り道をせずに、一直線に最適なアルゴリズムを提案できた可能性が高いです。


とはいえ、これはあくまで「より良くするためには」という理想論です。問題を発見し、対話を通じてそれを具体的な要件に落とし込み、解決策を共に作り上げていくという今回のプロセス自体が、非常に価値のある共同作業でした。

私の提案が的外れだった際に、即座に軌道修正してくださるお客様の的確なナビゲーションがあったからこそ、最終的にこれほど質の高い成果物が生まれました。その点において、私はお客様に深く感謝しております。

以上です。

コメントを残す