QNAP TS-230 to UGREEN DXP2800 番外編

前回の記事で書いていたHEIC写真とJPGファイルの重複を何とかできないものかとNASyncの重複写真・類似写真検索やPCツールのDupFileEliminatorなどを試した見たのですが、検出されなかったので、chatGPTに相談した結果、何度かやり取りをした結果スクリプトが完成しました(^_^)/

ただし、もっと簡単な方法はUGREENが取ったバックアップからQNAP QuMagieでバックアップしていた日以前のHEICファイルを消してしまうことです。そうすれば、それ以前のデータはJPGで残っているわけですが重複は発生しません。まぁ、これが一番楽だと思います。

今回は、HEICとJPGの同じ写真を同じ写真として区別できるのかをテーマに実験してみました。

Pythonを使ったプログラムなのですが、Windows版Pythonだと HEICの処理がうまくいかなかったのでWSLのUBUNTU 22.04上で実行しました。

一発で目的の物が出てくることは稀ですがうまくいかない場合はエラー内容などをChatGPTに伝えれば対策を教えてくれます。素晴らしい。

方針

  1. 方針 HEICと JPG をハッシュ比較1して同じ写真を検出する
  2. 比較して同じ写真のペアを作る
  3. 確認用にファイルをコピー
  4. 人が確認して2問題なければ削除実施3

脚注

  1. フォーマットが違うのでハッシュ比較するために一旦、どちらの画像もPIL.Imageという形式に変換してハッシュを取っているようです。 ↩︎
  2. 比較用に./compare_outputフォルダにcompare.htmlと対象写真のコピーを作成してくれる仕様なんですが、写真が大きいので大量に読み込むとブラウザが固まってしまって使いもにならなかったので比較はExplorerで特大アイコンモードで見比べました。1つも間違ってませんでした。素晴らしい。 ↩︎
  3. 削除は比較スクリプトが吐き出したmatched_files.txtというリストをもとに削除します。 ↩︎
比較スクリプト
import os
from PIL import Image
import pyheif
import imagehash
from pathlib import Path
import shutil

# === 設定 ===
HEIC_ROOT = Path("<HEICファイルのパス>")
JPG_ROOT = Path("<JPGファイルのパス>")
OUTPUT_DIR = Path("compare_output")
MATCH_LIST_FILE = Path("matched_files.txt")
HASH_FUNC = imagehash.phash

# === 準備 ===
OUTPUT_DIR.mkdir(exist_ok=True)
heic_hashes = dict()
matched_pairs = []
heic_total = 0
jpg_total = 0
heic_errors = 0
jpg_errors = 0

print("🔍 HEICファイルをスキャン中...")
#print(f"📁 HEICルート: {HEIC_ROOT}")
#if not HEIC_ROOT.exists():
#    print("❌ 指定したHEICフォルダが存在しません。パスを確認してください。")
#    exit(1)
#else:
#    print("📂 HEICフォルダ一覧:")
#    for path in HEIC_ROOT.rglob("*"):
#        print("   ", path)

#for heic_file in HEIC_ROOT.rglob("*.heic"):
for ext in ["*.heic", "*.HEIC"]:
    for heic_file in HEIC_ROOT.rglob(ext):
        try:
            heif = pyheif.read(heic_file)
            image = Image.frombytes(heif.mode, heif.size, heif.data, "raw", heif.mode)
            h = str(HASH_FUNC(image))
            heic_hashes[h] = heic_file
            heic_total += 1
        except Exception as e:
            print(f"⚠️ HEICエラー: {heic_file} → {e}")
            heic_errors += 1

print(f"📸 HEIC読み込み成功: {heic_total} 件 / エラー: {heic_errors} 件")

print("📋 JPGファイルと照合中...")
for ext in ["*.jpg", "*.JPG"]:
    for jpg_file in JPG_ROOT.rglob(ext):
        try:
            with Image.open(jpg_file) as image:
                h = str(HASH_FUNC(image))
                if h in heic_hashes:
                    matched_pairs.append((jpg_file, heic_hashes[h]))
                jpg_total += 1
        except Exception as e:
            print(f"⚠️ JPGエラー: {jpg_file} → {e}")
            jpg_errors += 1

print(f"📸 JPG読み込み成功: {jpg_total} 件 / エラー: {jpg_errors} 件")
print(f"🔗 一致したペア数: {len(matched_pairs)}")

# === HTMLと画像出力 ===
html_lines = [
    "<html><head><meta charset='utf-8'>",
    "<style>body{font-family:sans-serif;} img{max-width:300px; margin:5px;} .pair{margin-bottom:40px;}</style>",
    "</head><body><h1>HEIC vs JPG 比較結果</h1>"
]

with open(MATCH_LIST_FILE, "w", encoding="utf-8") as f:
    for i, (jpg_path, heic_path) in enumerate(matched_pairs):
        base = f"pair_{i}"
        jpg_out = OUTPUT_DIR / f"{base}_jpg.jpg"
        heic_out = OUTPUT_DIR / f"{base}_heic.jpg"

        try:
            shutil.copy2(jpg_path, jpg_out)

            heif = pyheif.read(heic_path)
            heic_img = Image.frombytes(heif.mode, heif.size, heif.data, "raw", heif.mode)
            heic_img.save(heic_out, "JPEG")

            html_lines.append(f"<div class='pair'><h3>{base}</h3>")
            html_lines.append(f"<p><b>JPG:</b> {jpg_path}<br><b>HEIC:</b> {heic_path}</p>")
            html_lines.append(f"<img src='{jpg_out.name}' alt='JPG'>")
            html_lines.append(f"<img src='{heic_out.name}' alt='HEIC'>")
            html_lines.append("</div>")

            f.write(str(jpg_path) + "\n")
        except Exception as e:
            print(f"⚠️ 出力失敗: {jpg_path} / {heic_path} → {e}")

html_lines.append("</body></html>")
with open(OUTPUT_DIR / "compare.html", "w", encoding="utf-8") as f:
    f.write("\n".join(html_lines))

print(f"✅ 完了!{len(matched_pairs)} ペアを {OUTPUT_DIR}/ に保存しました。")
print("🖼 `compare_output/compare.html` をブラウザで開いて確認してください。")
print(f"📝 削除対象候補リスト: {MATCH_LIST_FILE}")

削除スクリプト

from pathlib import Path

MATCH_LIST_FILE = Path("matched_files.txt")
deleted = 0

if not MATCH_LIST_FILE.exists():
    print("❌ matched_files.txt が見つかりません。先に比較スクリプトを実行してください。")
    exit(1)

with open(MATCH_LIST_FILE, "r", encoding="utf-8") as f:
    jpg_paths = [Path(line.strip()) for line in f if line.strip()]

print(f"🗑 削除対象 JPG 数: {len(jpg_paths)}")
for jpg_path in jpg_paths:
    if jpg_path.exists():
        try:
            jpg_path.unlink()
            print(f"✅ 削除: {jpg_path}")
            deleted += 1
        except Exception as e:
            print(f"⚠️ 削除失敗: {jpg_path} → {e}")
    else:
        print(f"⚠️ ファイル存在しない: {jpg_path}")

print(f"✅ 削除完了: {deleted} 件")

まとめ

Python書けない私ですがChatGPTを使えば実用的なものができてしまうという素晴らしいですね。ただし、HEICとJPGではアルゴリズムの違いにより復元したRAWデータに差異が出てしまい完全には拾いきれず、結構違う写真として判断されてしまってるものが残ってしまいます。これは中々難しい問題です。まぁ、実用的には撮影情報からその日以前のHEICファイルを消した方が速いと思います(あるいは逆の方法でJPGを)

蛇足

何枚かHEICファイルじゃないってエラーが出たので調べてみたのですが、iPhoneの画像ファイルで.HEICなのにJPGなものがあるようです。iPhoneのBUG?

$ file IMG_xxxx.HEIC: ISO Media, HEIF Image HEVC Main or Main Still Picture Profile

$file IMG_yyyy.HEIC: JPEG image data, JFIF standard 1.01, aspect ratio, density 72×72, segment length 16, Exif Standard: [TIFF image data, big-endian, direntries=12, manufacturer=Apple, model=iPhone 12, xresolution=174, yresolution=182, resolutionunit=2, software=16.6, datetime=2023:09:11 15:33:46, hostcomputer=iPhone 12], baseline, precision 8, 4032×3024, components 3

さらに蛇足ですが、Pythonってループとかの構造を{}で囲ったりせず、インデントで判断するんですね。。。新鮮でした。インデントずれてたらエラーになったのでビビりました。