写真データをバックアップしていて、ほぼ同じ写真が大量に保存されていることに気付くことがある。画像データは単純な比較では同じものかどうか判断できないことが多い。そこで、画像の特徴を抽出して類似画像を探す「画像ハッシュ」という手法を利用して、プログラムを作って整理してみよう。

  • 類似画像を探して整理するツールを作ろう

    類似画像を探して整理するツールを作ろう

どうして同じ写真が大量に記録される?

スマートフォンやデジタルカメラで写真を撮っていると、気付かないうちに、よく似た写真がたくさん保存されてしまうことがある。

被写体が半目になったり、手ぶれしてしまうのを防ぐために、シャッターを連写することもあるだろう。また、写真を明るくしたり、トリミングしたりすると、編集前と編集後の画像が別々に保存されることもある。さらに、スマートフォンからパソコンへ写真をコピーしたり、複数のバックアップを一つにまとめたりしたときに、同じ写真が重複して保存されることもある。

長年撮りためた写真を整理しようとするとき、まったく同じではないが、ほとんど同じ写真が大量に見つかることになる。このような写真を一枚ずつ目で確認して整理するのは大変なので、Pythonを使って、よく似た画像を自動的に見つけるプログラムを作ってみよう。

どうやってほぼ同じ写真を探す? - 画像ハッシュを使おう

画像データでほぼ同じ写真を探すのは、簡単ではない。画像データは、実際に見ないと、内容が同じかどうかを判定できないからだ。もちろん、マルチモーダルに対応したLLM(大規模言語モデル)を利用すれば、画像を説明させることはできる。しかし、ローカルにある大量の写真データを一枚ずつ読ませるのは、時間も費用もかかるので現実的ではない。

そこで、画像データを1枚ずつ比較するアルゴリズムを考えることになる。最初に考えつくのは、画像ファイルのバイナリを1バイトずつ比較する方式だろう。この方式であれば、ファイル名が違うだけの同一画像を検出できる。全く同じデータであれば、単純に一方を削除しても問題ないだろう。

ただし、今回探したいのは、ほぼ同じ写真だ。サイズがちょっと違うだけのものや、明るさ調整を施しただけのものは、人間の目にはほとんど同じに見えても、画像データ的には、全く異なるデータである。

実際のところ、ほぼ同じ画像を検出するには、「画像ハッシュ(Perceptual Hash)」と呼ばれる手法を利用できる。この方法では、画像を小さく縮小したり、グレースケールに変換したりして、画像の見た目の特徴を短い値として表現する。

そして、二つの画像から求めた画像ハッシュを比較する。ハッシュ値の違いが少なければ、見た目もよく似た画像であると判断できる。

具体的には、次の手順で画像ハッシュを利用して画像整理を行うプログラムを作ろう。

  1. 画像を縮小して、グレースケールに変換する
  2. 各部分の明るさを基準に0と1へ変換し、画像ハッシュを作る
  3. 画像同士のハッシュを比較して、似た画像を探す
  4. よく似た画像をグループにまとめて表示する
  5. ユーザーが残す写真を選び、それ以外を重複フォルダに移動する

画像ハッシュを求めるプログラムを作ってみよう

それでは、さっそく画像ハッシュを求める簡単なプログラムを作ってみよう。Python定番の画像ライブラリPillowだけを使ったプログラムだ。

"""画像ハッシュ(Perceptual Hash)を計算するプログラム"""
import sys
from PIL import Image

# ハッシュを作るときに画像を縮小するサイズ
HASH_SIZE = 8

def calc_imagehash(filename):
    """画像ファイルから画像ハッシュ(2進数の文字列)を計算する"""
    # (1) 画像を開いて、縮小しながらグレースケールに変換する
    img = Image.open(filename)
    img = img.convert("L")  # グレースケール(白黒)に変換
    img = img.resize((HASH_SIZE, HASH_SIZE))  # 小さく縮小

    # 各ピクセルの明るさ(0-255)を一覧にする
    pixels = list(img.get_flattened_data())

    # (2) 明るさの平均値を求めて、それを基準に0と1に変換する
    average = sum(pixels) / len(pixels)
    bits = ["1" if p > average else "0" for p in pixels]

    # (3) 0と1を並べた文字列を画像ハッシュとする
    imagehash = "".join(bits)
    return imagehash


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("使い方: python calc_imagehash.py 画像ファイル")
        sys.exit(1)
    # コマンドラインで指定された画像ファイルごとにハッシュを求めて表示する
    print(calc_imagehash(sys.argv[1]))

例えば、以下のような画像を用意して、プログラムを実行してみよう。

  • テスト用の画像「test.jpg」

    テスト用の画像「test.jpg」

上記のプログラムを「calc_imagehash.py」という名前で保存しよう。そして、ターミナル(WindowsならPowerShell、macOSならターミナル.app)を起動して、次のコマンドを実行しよう。

python calc_imagehash.py test.jpg

すると、次のような画像ハッシュが得られる。

1100010011111000111111001111110000000000110001110111100011011000

なお、Pillowのバージョンが古い場合は、get_flattened_dataメソッドが使えないので、次のコマンドでPillowをアップデートしておこう。

pip install --upgrade Pillow

StreamlitでローカルWebアプリにしよう

画像ハッシュの求め方が分かったところで、実際に画像を比較するツールを作ってみよう。今回のツールは、ローカルWebアプリにすると、ブラウザ上で画像を比較しながら整理できるので便利だ。PythonのWebアプリフレームワークであるStreamlitを使って、簡単なUIを作ってみよう。

Streamlitは、手軽に画像やグラフを表示できるWebアプリを作れるフレームワークだ。Pythonのコードを書くだけで、ブラウザ上にUIを作れるので、初心者でも簡単に使える。

今回は、簡単なプログラムを意識しつつ、次のようなプログラムを作った。

"""画像ハッシュを使って、よく似た写真を探して整理するWebアプリ"""
import os
import shutil
import streamlit as st
from PIL import Image
from calc_imagehash import calc_imagehash

# 対象とする画像の拡張子 --- (※1)
IMAGE_EXTS = (".jpg", ".jpeg", ".png")


def hamming_distance(hash1, hash2):
    """2つの画像ハッシュを比較して、異なるビットの数を返す"""
    return sum(a != b for a, b in zip(hash1, hash2))


def find_similar_groups(folder, threshold):
    """フォルダ内の画像をハッシュで比較し、似た画像のグループを作る"""
    # フォルダ内の画像ファイルを一覧にする --- (※2)
    files = [f for f in os.listdir(folder) if f.lower().endswith(IMAGE_EXTS)]

    # 各画像の画像ハッシュを求める
    hashes = {}
    for filename in files:
        path = os.path.join(folder, filename)
        hashes[filename] = calc_imagehash(path)

    # ハッシュ同士を比較して、よく似た画像をグループにまとめる --- (※3)
    groups = []
    for filename, imagehash in hashes.items():
        # すでにあるグループの代表画像と比較し、似ていれば同じグループに入れる
        for group in groups:
            leader = group[0]
            if hamming_distance(imagehash, hashes[leader]) <= threshold:
                group.append(filename)
                break
        else:
            # どのグループにも似ていなければ、新しいグループを作る
            groups.append([filename])

    return groups


def main():
    # StreamlitのUIを作る --- (※4)
    st.title("似た写真を探して整理する")

    folder = st.text_input("画像フォルダのパス", value=os.getcwd())
    threshold = st.slider("似ていると判定する距離(小さいほど厳しい判定)", 0, 20, 5)

    if st.button("フォルダをスキャンする"):
        if not os.path.isdir(folder):
            st.error("フォルダが見つかりません")
        else:
            st.session_state["groups"] = find_similar_groups(folder, threshold)
            st.session_state["folder"] = folder

    # よく似た画像のグループを表示する
    groups = st.session_state.get("groups", [])
    folder = st.session_state.get("folder", folder)

    keep_files = {}
    for i, group in enumerate(groups):
        if len(group) < 2:
            continue  # 似た画像がないものは表示しない

        st.subheader(f"グループ{i + 1}({len(group)}枚)")
        cols = st.columns(len(group))
        for col, filename in zip(cols, group):
            path = os.path.join(folder, filename)
            img = Image.open(path)
            col.image(img, caption=f"{filename} ({img.width}x{img.height})")
            # 残す写真をユーザーに選んでもらう(最初の1枚を初期選択にする)
            key = f"keep_{i}_{filename}"
            keep_files[key] = col.checkbox("残す", value=(filename == group[0]), key=key)

    # ユーザーが選んだ写真以外を重複フォルダへ移動する --- (※5)
    if groups and st.button("選んだ写真以外を重複フォルダへ移動する"):
        dup_folder = os.path.join(folder, "duplicates")
        os.makedirs(dup_folder, exist_ok=True)

        moved = 0
        for i, group in enumerate(groups):
            if len(group) < 2:
                continue
            for filename in group:
                key = f"keep_{i}_{filename}"
                if not st.session_state.get(key, False):
                    shutil.move(os.path.join(folder, filename), os.path.join(dup_folder, filename))
                    moved += 1

        st.success(f"{moved}枚の写真をduplicatesフォルダへ移動しました")
        del st.session_state["groups"]  # スキャン結果をクリアする


if __name__ == "__main__":
    main()

プログラムを簡単に確認しよう。このプログラムは、指定したフォルダ内の画像を調べ、画像ハッシュが近い写真をグループにまとめて表示する。ユーザーは各グループから残したい写真を選び、それ以外をduplicatesフォルダへ移動するものだ。

プログラムの(※1)では、対象とする画像形式を指定する。(※2)では、フォルダ内の画像を取得する。指定フォルダ内のファイル一覧を取得し、拡張子がIMAGE_EXTSに含まれる画像だけを取り出している。その後、それぞれの画像についてcalc_imagehash()を呼び出し、画像ハッシュを計算してhashes辞書に保存する。

(※3)では、似た画像をグループにまとめる。画像ハッシュ同士のハミング距離を調べ、指定したthreshold以下であれば、よく似た画像であると判断する。すでに作成したグループの先頭画像を代表画像として比較し、似ていればそのグループへ追加する。どのグループにも当てはまらなければ、新しいグループを作成する。

(※4)では、Streamlitで画面を作る処理を行う。ユーザーは画像フォルダのパスと、類似画像と判断するハミング距離のしきい値を指定する。「フォルダをスキャンする」ボタンを押すと類似画像の検索が行われる。

最後に、(※5)で、不要な画像を別フォルダへ移動する。「選んだ写真以外を重複フォルダへ移動する」ボタンを押すと、チェックされていない画像をduplicatesフォルダへ移動する。画像を直接削除するのではなく別フォルダへ移動することで、誤判定があった場合にも元に戻せるようにしている。

アプリを実行しよう

アプリを実行するために、パッケージ「Streamlit」をインストールしよう。ターミナルで次のコマンドを実行する。

pip install streamlit

先ほど作った「calc_imagehash.py」と同じフォルダに、上記の「webapp.py」を保存しよう。そして、ターミナルで次のコマンドを実行して、Webアプリを起動しよう。

streamlit run webapp.py

Webアプリを実行すると、次のような画面が表示される。

  • 類似画像を整理するアプリを実行したところ

    類似画像を整理するアプリを実行したところ

まとめ

以上、今回は、Pythonを使って、よく似た画像を探して整理するプログラムを作った。画像ハッシュを使うことで、サイズが違ったり、明るさが違ったりする画像でも、よく似た画像を見つけることができる。

なお、今回のプログラムでは、グループ内の最初の画像が1枚選択されるだけだ。もっと使い勝手のよいツールにするために、画像サイズの大きなものを自動的に残すなど、より高度な条件を追加すると良いだろう。自分の上条件に合うように、いろいろと改良してみるのも面白いだろう。

自由型プログラマー。くじらはんどにて、プログラミングの楽しさを伝える活動をしている。代表作に、日本語プログラミング言語「なでしこ」 、テキスト音楽「サクラ」など。2001年オンラインソフト大賞入賞、2004年度未踏ユース スーパークリエータ認定、2010年 OSS貢献者章受賞。技術書も多く執筆している。直近では、「実践力をアップする Pythonによるアルゴリズムの教科書(マイナビ出版)」「シゴトがはかどる Python自動処理の教科書(マイナビ出版)」「すぐに使える!業務で実践できる! PythonによるAI・機械学習・深層学習アプリのつくり方 TensorFlow2対応(ソシム)」「マンガでざっくり学ぶPython(マイナビ出版)」など。