Python+OpenCVでaHashの計算とか

画像の基本的な特徴である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)

アルゴリズムが明確に定められていないので結果は異なっている。

参考