貧乏サラリーマンが副業せずにどうにか稼ぎたい

副業禁止のサラリーマンがなんとか家で稼げないか挑戦します!

完全一致画像の検索

完全一致画像の検索

ローカルに画像が増えてきて、容量を削減したいけど画像数が多すぎてどっから消せばいいのかわからないという現象に悩まされてきたので、とりあえず完全一致している画像を検索して消すことにしました。

環境

  • Windows11
  • Anaconda
  • Python 3.8.12

手順

もっとスマートな手順もあるのかもしれませんが、愚直に行きます。

検索対象(インプット)

大量の画像が格納されているフォルダ

検索手順

  1. 大量の画像が格納されているフォルダから画像ファイルの一覧を取得
  2. 画像を一枚ずつOpenCVを使って画像をベクトル化して画像ベクトルのリストを作成
  3. 画像ベクトルのリストを2重ループにして、コサイン類似度が1のものを同一画像としてピックアップして、別フォルダにコピー

処理結果(アウトプット)

類似画像ごとにフォルダ分けされたフォルダ

実装

では早速実装に移ります。

0. ライブラリのインポート

いつも特にライブラリのバージョンを意識せずにpipしてしまうので、たまに動かなかったりしますが、今回はGPUも使ってないし、特にバージョンを意識する必要があったパッケージはなかったと思いますので、足りてないパッケージはとりあえずpipで入れてください。

import os
from glob import glob
import shutil
from tqdm import tqdm
import cv2
import numpy as np
import sklearn
from sklearn.decomposition import PCA

1. 画像一覧の取得

# とりあえず以下の拡張子のファイルを画像とする
IMAGE_TYPE = [
    '.png',
    '.jpg',
    '.jpeg',
    '.bmp'
]

# globでフォルダ内のファイルのパスを全取得する
files = glob(r"image\*")

# ファイル名から拡張子を取り出し、画像かどうかを判定してリストに追加する
imagefiles = []
for file in tqdm(files):
    root, ext = os.path.splitext(file)
    if ext in IMAGE_TYPE:
        imagefiles.append(file)

2. 画像のベクトル化

# 読み込んだ画像のベクトルを格納するリスト
proccessed = []
# 読み込ん画像のパスを格納するリスト
imagefilenotempties = []

# 1. で読み込んだ画像のパスのリストをforループ
# どれくらい時間がかかるか知りたいのでtqdmを使う
for i, imgfile in enumerate(tqdm(imagefiles)):
    # 画像の読み込み処理
    img = cv2.imread(imgfile)
    # imreadでNoneが返ってくることがあるので例外処理としての判定
    if not img is None:
        # 画像ごとにサイズが違うと比較ができないので、サイズを圧縮して合わせる
        resized = cv2.resize(img, (64,64), cv2.INTER_LINEAR)
        proccessed.append(resized)
        imagefilenotempties.append(imgfile)

print(len(proccessed))
print(len(imagefilenotempties))

# 1枚の画像に対して64x64x3のベクトルを1x12288のベクトルに変換
features = np.array(proccessed)
features = features.reshape(features.shape[0], -1)

画像の圧縮を行っているcv2.resizeの引数のcv2.INTER_LINEARについては、以下の記事を参考として一番効率が良さそうだったので使用しています。デフォルトらしい? pystyle.info

3. 各画像に対してコサイン類似度を計算して同一画像をピックアップ

def similary_cossain(x, y):
    result = np.dot(x,y)/(np.linalg.norm(x)*np.linalg.norm(y))
    return result

コサイン類似度を計算する関数を定義、以下を参考 qiita.com

# 結果の出力先を作成
resultsavepath = 'searchresult'
if os.path.exists(resultsavepath):
    shutil.rmtree(resultsavepath)
os.mkdir(resultsavepath)

# コサイン類似度計算のため、各画像の次元数を2次元まで圧縮する
# 特徴量を失わないようにPCAを利用して圧縮
pca = PCA(n_components=2)
pcaresult = pca.fit_transform(features)

# 類似画像と判定された画像を格納するリスト
skipimgs = []

# ファイルパスとベクトルを同時にforループする
# 類似画像検索対象を取り出す
for searchfilepath, searchfeature in zip(tqdm(imagefilenotempties), pcaresult):
    # 既に類似画像と判定されていないか確認
    if searchfilepath not in skipimgs:
        # 比較対象を取り出す
        for filepath, feature in zip(imagefilenotempties, pcaresult):
            # 検索対象自身でないことと既に類似画像と判定されていないか確認
            if searchfilepath != filepath and filepath not in skipimgs:
                # コサイン類似度を計算
                simi = similary_cossain(searchfeature, feature)
                # コサイン類似度が1ならば同一画像
                if simi > 1:
                    skipimgs.append(filepath)

                    # 類似画像を結果出力先にコピー
                    sameimgsavedir = os.path.join(resultsavepath, searchfilepath.split('\\')[-1].split('.')[0])
                    if not os.path.exists(sameimgsavedir):
                        os.mkdir(sameimgsavedir)
                        shutil.copy2(searchfilepath, os.path.join(os.getcwd(), sameimgsavedir, searchfilepath.split('\\')[-1]))
                    # 類似画像検索対象側も結果出力先にコピー
                    shutil.copyfile(filepath, os.path.join(os.getcwd(), sameimgsavedir, filepath.split('\\')[-1]))

結果

90,000枚くらいの画像で上記ソースを実行したところ、3.の処理で21時間くらいかかりました。
あまりにも効率が悪い。 3,000枚なら2分弱で終わりました。n2オーダーの恐ろしさを改めて実感。

今後

せっかく途中でPCAを挟んでいるのでk-meansでクラスタリングを挟んで比較範囲を狭めることでもうちょっと効率的に類似画像の検索ができそう。

元々は完全一致ではなく、類似画像の検索ができないかなと作ってたツールですが、現時点ではこれくらいでツールとして使えそうかなと思いましたので記事にしてみました。