このブログでは、記事に載せる写真を取り込むたびに、保存したファイルが出典の画像と同じものかを機械的に確かめている。出典から画像を取り直し、手元のファイルと並べて比べるのだが、保存のときに縮小と再圧縮をかけているので、ファイルの中身は1バイト単位ではまず一致しない。そこで使っているのが、画像の見た目から短い数字を作る「知覚ハッシュ」だ。検査のスクリプトには64ビットのdHashが入っていて、2つのハッシュで食い違うビットが10個以下なら同じ画像とみなしている。
この「10」という数字は、目安として決めたもので、どこまで加工に耐え、どこから別の画像を取り違えるのかをきちんと測ったことはなかった。そこで、Pythonでよく使われる知覚ハッシュの実装を並べ、写真1枚にさまざまな加工をかけたときの距離と、手元にある画像同士を総当たりで比べたときの距離を記録しておくことにした。使ったのはPython 3.12.10、ImageHash 4.3.2、Pillow 13.0.0、NumPy 2.5.4、SciPy 1.18.1で、作業用の仮想環境にまとめて入れている。
ファイルのハッシュと、見た目のハッシュ 左が元の写真、右が強く圧縮した写真。ファイルとしては別物でも、知覚ハッシュでは距離0になったPhoto: JJ Harrison, side-by-side comparison by Ibrahim.ID / CC BY-SA 3.0 MD5やSHA-256のようなハッシュは、入力が1ビットでも変われば出力がまったく別の値になるように作られている。改ざんを見つけるにはこの性質が欠かせないが、画像の比較には向かない。JPEGで保存し直しただけ、幅を半分にしただけでも、ハッシュ値は似ても似つかない値になり、「同じ写真かどうか」という問いには答えてくれない。
知覚ハッシュは、その逆を狙っている。画像を小さく縮めて細部を捨て、明るさの大まかな並びだけから数十ビットの値を作るので、人の目に同じに見える画像なら、ほぼ同じビット列になる。2つの値がどれだけ違うかは、食い違うビットの数、つまりハミング距離で測る。64ビットなら距離は0から64までで、0なら見た目がほぼ同じ、32前後なら無関係な画像同士と同じくらい離れている、と読む。
左の図は、1枚の鳥の写真を左右に並べ、右側だけ強く圧縮したものだ。この画像を真ん中で2つに切って比べると、aHash・dHash・pHashのいずれも距離は0、このブログの検査に入っているdHashでも1だった。背景のぼけた緑がくすみ、羽の色の境目が粗くなっていても、明るさの並びは変わっていないということだ。ファイルのハッシュが改ざんの有無を問うのに対し、知覚ハッシュは「人が見て同じ絵か」を問う道具だと考えると分かりやすい。
aHashとdHashは、8×8の明るさを並べるだけ 同じ写真を色の成分(上)と明るさ(中)に分けた例。知覚ハッシュが使うのは中段の明るさだけだPhoto: Tom Axford 1 / CC BY-SA 4.0 ImageHashに入っている代表的な3種類は、どれも最初にカラー画像をグレースケールに変える。Pillowでconvert("L")を呼ぶと、赤・緑・青の値に1000分の299、587、114を掛けて足した値が明るさになる。緑の重みが大きいのは、人の目が緑の明るさに敏感だからだ。色の違いはここで捨てられるので、写真をモノクロにしても3種類とも距離は0のままだった。
aHash(平均ハッシュ)は、いちばん素直な作り方をしている。画像を8×8の64画素まで縮め、64個の明るさの平均を出し、各画素が平均より明るければ1、暗ければ0とする。64画素を左上から順に並べれば、そのまま64ビットの値になる。計算は軽いが、画像全体の明暗の配置しか見ていないので、白い背景の真ん中に暗い物が立っている写真は、写っている物が何であれ似た値になりやすい。
dHash(差分ハッシュ)は、横に9画素、縦に8画素まで縮め、各行で隣り合う画素の明るさを比べる。1行に比べる組が8つ、8行で64ビットになる。平均と比べるのではなく、隣との差の向きを記録するので、画像全体が明るくなったり暗くなったりしても、差の向きはあまり変わらない。このブログの検査に入っているのもこの方式で、Pillowだけで書ける短さも選んだ理由だった。
from PIL import Image def dhash(im): g = im.convert("L").resize((9, 8)) px = list(g.get_flattened_data()) return sum(1 << i for i in range(64) if px[(i // 8) * 9 + i % 8] > px[(i // 8) * 9 + i % 8 + 1]) def distance(a, b): return bin(dhash(a) ^ dhash(b)).count("1") 2つの値の排他的論理和を取ると、食い違うビットだけが1になる。その1の数を数えればハミング距離だ。aHashとpHashは2011年、dHashは2013年に、画像の解析を仕事にしているニール・クラウェッツが自身のブログで紹介した方式で、ImageHashの関数の説明にもその記事が挙がっている。
pHashは32×32に縮めてから、周波数の低い成分を見る 8×8の離散コサイン変換で使う64通りの模様。pHashは左上に近い、粗い模様の成分だけを残すPhoto: Devcore / Public domain pHashは、画像を一度周波数の成分に分解してから判定する。ImageHashのphashは、まず画像を32×32まで縮めてグレースケールにし、縦と横に1回ずつ離散コサイン変換(DCT)をかける。DCTは、画像を左の図のような縞模様の重ね合わせとして表し、それぞれの模様がどれだけ含まれているかを数字にする変換で、JPEG圧縮の中でも使われている。左上ほどゆるやかな明暗の変化、右下ほど細かい模様に当たる。
pHashが残すのは、32×32の結果のうち左上の8×8、つまり周波数の低い64個の成分だけだ。細かい模様はノイズや圧縮のむらに左右されやすいので捨て、画像の骨格に当たる粗い明暗の分布だけで判定する。64個の値の中央値より大きければ1、そうでなければ0としてビットを並べる。ソースを読むと、中央値を求める範囲には画像全体の平均の明るさを表す左上の1個も含まれていた。平均値で比べるphash_simpleという別の関数もあり、こちらは左上の1列目を除いた8×8を平均と比べる。
ほかにも、DCTの代わりにウェーブレット変換を使うwhash、色の分布を見るcolorhash、画像を領域に分けて部分ごとにハッシュを取るcrop_resistant_hashがある。いずれも結果はImageHashという型で返り、2つを引き算するとハミング距離が整数で返ってくる。
import imagehash from PIL import Image a = Image.open("a.jpg") b = Image.open("b.jpg") print(imagehash.phash(a) - imagehash.phash(b)) # 0〜64 1枚あたりの計算時間も測った。手元の画像549枚を読み込み済みの状態で、aHashとdHashが1枚あたり約3.4ミリ秒、pHashが約5.0ミリ秒だった。大半は元の大きさから縮小する処理にかかっていて、DCTの分はわずかだ。数万枚程度なら、どれを選んでも速度で困ることはないと思う。
写真1枚を14通りに加工して、距離を測る 加工の元にした写真。左は2009年にマイクロソフトとPhotoDNAを作ったハニー・ファリドPhoto: John Manard / CC BY-SA 4.0 元にしたのは、右の幅1000画素の写真だ。これに縮小、再圧縮、明るさとコントラストの変更、トリミング、回転、左右反転などを1つずつかけ、元の写真とのハミング距離を4つのハッシュで測った。「ブログ」の列は、このブログの検査に入っているPillowだけのdHashで、ImageHashのdHashとは縮小に使うフィルターが違う。ImageHashはランチョス、Pillowのresizeは既定でバイキュービックを使う。
...