画像の基本的な特徴であるaHash(Average Hash)をPython+OpenCVで計算する。 Pythonのバージョンは3.9.2、OpenCVのバージョンは4.5.1
aHashは画像を小さくしてグレースケールにして平均で二値化して一次元化するだけの処理で、何も難しいことはない。 (画像を扱うライブラリとしてPillow(PIL)というライブラリもある。OpenCVの方が高機能らしいので今回はOpenCVを使用してみる。)
まず画像を読み込む
import cv2 as cv image = cv.imread('sample.png')
cv.imreadはpathlib.Pathを受け付けないことに注意する。
画像はnumpy.ndarrayで返ってくる。画像が読み込めなかった場合、imread関数はNoneを返す。もう少し丁寧に書くと、
#!/usr/bin/env python import cv2 as cv import pathlib class ImageProcessError(Exception): ... def do_some_test(): image_file = pathlib.Path('sample.png') if (image := cv.imread(str(image_file.resolve()))) is None: raise ImageProcessError(image_file) print('Image loaded successfully') print(type(image)) # -> <class 'numpy.ndarray'> height, width, depth = image.shape print(f'{height = }') print(f'{width = }') print(f'{depth = }') if __name__ == '__main__': do_some_test()
こうして得られた画像に対して、aHash処理を行う
import cv2 as cv import numpy as np def calc_ahash_using_opencv(image_file): # 画像読み込み if (image := cv.imread(str(image_file.resolve()))) is None: raise ImageProcessError(image_file) # 8x8に縮小 image_8x8 = cv.resize(image, (8, 8)) # グレースケール化 image_gray = cv.cvtColor(image_8x8, cv.COLOR_BGR2GRAY) # 平均 h, w, *_ = image_gray.shape average = np.sum(image_gray) / (h * w) # 閾値二値化 image_binary = (image_gray > average) # 一次元化 seq = np.ravel(image_binary) # 0/1の文字列として連結 seq_str = ''.join([str(int(value)) for value in seq]) # 2進数で読み込んで整数にしてから16進数16桁の文字列で返す value = int(f'0b{seq_str}', 0) return f'{value:016x}'
最初のcv.resizeだが、縮小にせよ拡大にせよ、状況によってはinterpolationを気にした方が良いだろう。
cv.cvtColorについて、カラーをグレーにマップする際の変換は、非常に単純なモデルならただ平均をとるだけだが、実際にはもっと難しい問題が絡んでいる。OpenCVのバージョン4.5.1のドキュメント
https://docs.opencv.org/4.5.1/de/d25/imgproc_color_conversions.html
によると、
$$Y←0.299⋅R+0.587⋅G+0.114⋅B$$
二値化について、ここでは、直接numpyのマスク配列を作っているが、OpenCVの関数cv.thresholdを使用しても良いだろう。
_, image_binary = cv.threshold(image_gray, average, 1, cv.THRESH_BINARY)
cv.thresholdの第三引数maxvalは閾値を超えたピクセルの変換後の値である。ここでは単純に0/1にしている。カメラで撮った写真のような場合は一見同じ明るさに見えても位置によって明るさが異なることが普通である。そういう光源に対するロバスト性を求める場合はそもそも今回のaHashは適切でないだろうが、派生的なやり方でadaptiveThreshold関数を使うという手もある。
np.ravelはndarrayを一次元化する関数で、flattenと比べると高速らしい。
最後に、0/1にして文字列にした後で64ビット整数として読み込み、16進数16桁の文字列にして返している。 http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.htmlによると、ビット列を「なんらかの方法で」64bit整数に変換するそうだ。ビットをどの順番にするかは書いていない。
せっかくなのでPillowとImageHashを使用する方法と比較してみる。
import PIL import imagehash def calc_ahash_using_imagehash(image_file): try: image = PIL.Image.open(image_file) except (FileNotFoundError, PIL.UnidentifiedImageError): raise ImageProcessError() ahash = imagehash.average_hash(image) return ahash
imagehash.average_hashが返すのはimagehash.ImageHashクラスのオブジェクトであり、イコールやマイナスのような演算子で手軽にハッシュの処理ができるそうだ。 結果を比較してみる。
def compare_ahash(image_file): ahash_opencv = calc_ahash_using_opencv(image_file) print(ahash_opencv) # -> 001838bfdbebc400 (depend on image_file) ahash_imagehash = calc_ahash_using_imagehash(image_file) print(ahash_imagehash) # -> 0038383e3ffbf600 (depend on image_file) def do_some_test(): image_file = pathlib.Path('sample.png') compare_ahash(image_file)
アルゴリズムが明確に定められていないので結果は異なっている。