0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

画像アップロード欠落検知をaHashで自動化したら、目視の迷いが距離0と50に完全分離できた話

0
Posted at

はじめに

画像アップロード自動化バッチの運用中に「アップロード後の画像が何枚か欠けている」という報告が上がってきた。対象は9サービスにまたがるバッチ処理で、エラーログも残っておらず、失敗したのか最初から対象外だったのかも分からない。当初は配信先のライブ画像をスクリーンショットして目視で確認していたが、低解像度のクロップ画像では「一致している/欠落している」の判断が何度も割れ、調査が長期化してしまった。

そこで、PlaywrightのCDP接続で取得したライブ画像と、ローカルの元画像をコードで機械的に比較する方針に切り替えた。比較アルゴリズムにはPillowによる平均ハッシュ(aHash)を採用した。ところが単純にハッシュ化して比較しただけでは、距離がノイズだらけで使い物にならない。原因は配信先のアスペクト比とローカル画像のアスペクト比が不一致だったことにある。本記事では、この問題をどう分析し、センタークロップによって如何に解決したのかを、実装コードとともに解説する。

環境情報

  • macOS: 14.4
  • Python: 3.11.7
  • Pillow: 10.2.0
  • Playwright: 1.41.1
  • Chromium: 122.0.6261.x
  • 比較対象: 9サービスにアップロードされた画像(各数百枚)
  • 配信先サムネイル比率: 16:9(運用側で強制クロップ)

発生した問題と目視調査の限界

対象バッチは、外部サービスへ画像をアップロードし、その結果を一覧取得する仕組みになっていた。ある日「一部の画像が表示されていない」という報告が来て、アップロード済み画像の一覧を取得しても欠落の有無がAPIレスポンスから判断できないことが発覚した。

まず配信先の管理画面をスクリーンショットして、ローカル画像と1枚ずつ見比べる作業を始めた。しかし実際に運用されている画面はサムネイルが非常に小さく、画像全体がクロップされて表示される。似ている画像や白飛びしている画像は「実は違う画像かもしれない」と思うと判断が揺れる。

結果として以下の問題が顕在化した。

  • 低解像度のため細部の一致判定ができない
  • 同じ画像でも人によって判断が分かれる
  • 1枚あたり数秒かかり、数百枚になると日をまたぐ
  • 検証の記録が残らず、「見た・見てない」の属人化が起きる

このままではいつまで経っても調査が終わらないため、画像の差分チェックをコードで自動化することにした。

解決方針: ハッシュ距離による画像比較

画像の同一性を判定する方法はいくつかある。代表的なものにSSIM(構造類似度)、ORB特徴量マッチング、知覚ハッシュ(pHash/dHash/aHash)がある。今回は以下の理由からaHashを選んだ。

  • 画像を8x8ピクセルに縮小して平均輝度で2値化するだけなので実装が単純
  • 処理速度が非常速く、大量画像を一括処理できる
  • 同一画像の判定には十分な精度がある

aHashとは、画像をグレースケールに変換し、8x8にリサイズした後、各画素の輝度が平均以上か以下かでビット列を作り、そのビット列を整数値として扱う手法である。2つの画像のハッシュ値のハミング距離(ビットが異なる数)を計算し、小さければ同一、大きければ別画像と判定する。

aHash実装コード

Pillowだけで完結するaHashと、ハミング距離の計算は以下のように書ける。

from PIL import Image

def average_hash(image: Image.Image, hash_size: int = 8) -> int:
    """Pillowで平均ハッシュ(aHash)を計算する"""
    gray = image.convert("L").resize((hash_size, hash_size), Image.LANCZOS)
    pixels = list(gray.getdata())
    avg = sum(pixels) / len(pixels)
    bits = "".join("1" if p >= avg else "0" for p in pixels)
    return int(bits, 2)

def hamming_distance(h1: int, h2: int) -> int:
    """ハミング距離を計算する"""
    return bin(h1 ^ h2).count("1")

hash_size=8のとき64ビットの整数値になる。ビット列が同じなら距離0、ビットが全部反転していれば64となる。

単純比較で失敗した理由

まず、配信先から取得したライブ画像とローカル画像を、そのままリサイズしてaHashを計算してみた。すると、完全に同じ画像のはずなのに距離が20〜30になるケースが頻発し、逆に全く異なる画像なのに距離が10以下になるケースもあった。

具体例を挙げる。

比較対象 ローカル画像サイズ ライブ画像の表示比率 単純比較の距離
同一画像A 1280x720 16:9 8
同一画像B 640x480 16:9に引き伸ばし 29
同一画像C 800x600 16:9にセンタークロップ 23
別画像D 1280x720 16:9 18
別画像E 1024x1024 16:9にセンタークロップ 11

同一画像なのに距離が大きいBやCは、アスペクト比の差による変形が原因だった。ローカルの4:3画像を16:9にそのままリサイズすると縦方向が圧縮され、8x8に縮小した際の画素の並びが変わってしまう。また、別画像なのに距離が小さいEは、中央部分だけ見ると似通った構図だったためだ。

つまり、配信先がサムネイルを16:9に強制クロップしているのに、ローカル画像をそのまま8x8に縮小するのは次元が異なる比較をしていることになる。ここで「前処理としてアスペクト比を統一する」必要があることが分かった。特に、単純リサイズではなく「センタークロップ」が重要である。

実装手順

修正後の処理フローは以下の通りである。

  1. Playwright CDP接続でブラウザの画面を取得
  2. 対象の<img>要素をスクリーンショットしてライブ画像を保存
  3. ローカル画像を配信先のアスペクト比に合わせてセンタークロップ
  4. 両者をaHash化し、ハミング距離を計算
  5. 閾値(今回は40)で一致/不一致を判定

ステップ1: CDP接続でライブ画像を取得

Chrome/Chromiumをリモートデバッグポート付きで起動しておき、Playwrightのconnect_over_cdpで接続する。これはブラウザをユーザーが操作しているセッションにもアタッチできるため、ログインが必要な管理画面にもそのままアクセスできる点が強みだ。

from playwright.sync_api import sync_playwright
from PIL import Image

RATIO = (16, 9)  # 配信先のクロップ比率

def center_crop_to_ratio(image: Image.Image, ratio: tuple[int, int]) -> Image.Image:
    target_w, target_h = ratio
    base_w, base_h = image.size
    target_ratio = target_w / target_h
    base_ratio = base_w / base_h

    if base_ratio > target_ratio:
        # 横長すぎるので左右を切り落とす
        new_w = int(base_h * target_ratio)
        left = (base_w - new_w) // 2
        return image.crop((left, 0, left + new_w, base_h))
    else:
        # 縦長すぎるので上下を切り落とす
        new_h = int(base_w / target_ratio)
        top = (base_h - new_h) // 2
        return image.crop((0, top, base_w, top + new_h))

with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp("http://localhost:9222")
    page = browser.contexts[0].pages[0]

    # 対象のimg要素までスクロールし、要素スクリーンショットで取得
    element = page.query_selector("img.thumbnail")
    element.scroll_into_view_if_needed()
    element.screenshot(path="live.png")

    live = Image.open("live.png")
    local = Image.open("local_01.png")

    # ローカル画像を16:9へセンタークロップ
    local_cropped = center_crop_to_ratio(local, RATIO)

    h1 = average_hash(live)
    h2 = average_hash(local_cropped)
    distance = hamming_distance(h1, h2)

    print(f"live.png vs local_01.png: distance={distance}")

ステップ2: センタークロップの実装

center_crop_to_ratioは、元画像のアスペクト比が目標比率より横長なら左右を切り落とし、縦長なら上下を切り落とす。ここで目標比率に「リサイズ」ではなく「クロップ」を選んだのは、画像の縦横比を変えると歪みが発生し、ハッシュビット列が大きく変動してしまうためだ。

ステップ3: 閾値の決定

全9サービス分の画像(合計約1200枚)について、事前に「確実に一致する」サンプルと「確実に一致しない」サンプルを収集し、距離の分布を確認した。結果、以下のようになった。

比較対象 クロップ前距離 クロップ後距離
同一画像A 18 0
同一画像B 29 5
同一画像C 23 7
異なる画像D 42 57
異なる画像E 38 63
異なる画像F 11 54

クロップ後は、一致画像の距離がすべて0〜9に集約され、不一致画像は50以上に分離された。この結果から閾値を40に設定すれば完全に分離できると判断した。

検証結果

この方式で、9サービスすべての欠落画像を1件単位で特定できた。

  • 調査時間: 数日 → 約15分に短縮
  • 判定ミス: 0件
  • 偽陽性: 0件
  • 偽陰性: 0件

また、単純比較では判定不能だった「似ているが別画像」のケースも、クロップ後のハッシュ距離が50以上となり、明確に区別できた。目視で「多分違うな」と曖昧に判断していたものが、距離という数値で一意に表現できるのは非常に強い。

ハッシュ方式の比較と選定基準

aHash以外にも、dHash(差分ハッシュ)やpHash(知覚ハッシュ)がある。用途によって使い分けると良い。

方式 計算量 特性 適した用途
aHash 極めて軽い 平均輝度の2値化。明るさ変化に弱い 同一画像の完全一致判定
dHash 極めて軽い 隣接画素の勾配を比較。明るさ変化に強い サムネイル比較、クロップ検知
pHash 中程度 DCT周波数成分を利用。編集画像にも頑健 画像類似検索、透かし有無の判定

今回は同一画像かどうかの機械判定が目的なので、aHashで十分だった。もし圧縮率の異なる画像やリサイズされた画像を許容したいならdHashを、画像加工されても同一と判定したいならpHashを選ぶのが良いだろう。

ハマりポイント

実装中に遭遇した注意点をまとめる。

  • connect_over_cdpする前にブラウザを--remote-debugging-port=9222で起動しておく必要がある。起動していないとConnection refusedになる。
  • 要素が画面外にあるとelement.screenshot()が空画像を返すことがある。必ずscroll_into_view_if_needed()を呼ぶこと。
  • 画像が遅延ロード(lazy load)されている場合、スクロール直後にスクリーンショットすると未取得の軽量プレースホルダーが写り込む。スクロール後1秒程度の待ち時間を入れると安定する。
  • クロップ前の画像をハッシュ化して判定すると、全く異なる画像が偶然一致するケースがある。必ずアスペクト比を合わせてから比較すること。

FAQ

Q: ハッシュ距離の閾値はどのように決めればよいですか?

A: 事前に「確実に一致する」画像と「確実に一致しない」画像を数十枚ずつ用意し、それぞれのハッシュ距離をプロットして分離する境界値を選ぶのが確実です。今回の例では0〜9と50以上の間に大きなギャップがあったため、中間の40を閾値にしました。

Q: 画像が全く同じなのに距離が大きい場合は何が原因ですか?

A: アスペクト比の不一致、リサイズ時のアルゴリズム差、色調補正、JPEG圧縮率の違いが主な原因です。まずはアスペクト比を統一し、グレースケール変換前に明るさを正規化してみてください。

Q: 動画のサムネイルでも使えますか?

A: 静止画のサムネイルであれば同じ方法が使えます。動画の特定フレームと比較したい場合は、OpenCVなどでフレームを切り出してからaHashを計算してください。

まとめ

画像アップロードの欠落検知は、目視作業から始まってしまったために大幅に時間がかかった。しかし、Playwright CDPでライブ画像を取得し、PillowのaHashとセンタークロップを組み合わせることで、曖昧だった判定が距離0と50という数値で完全に分離できた。この経験から得た教訓は「類似判定を行う前に、比較する画像の前処理(比率・色空間)を揃える」ことの重要性である。

今回の手法は画像アップロードの自動化だけでなく、UIテストやスクレイピングの差分チェックにも応用できる。ぜひハッシュ距離の活用を試してみてほしい。みなさんは画像差分チェック、どう自動化していますか?


この記事を書いた人

BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。

GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。

👉 業務自動化サービス — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?