今さらガボールフィルタを触ってみた。画像の「向き」を見るとはどういうことか
約15分で読めます

今さらガボールフィルタを触ってみた。画像の「向き」を見るとはどういうことか


最近、仕事でVLM(Vision Language Model)を触ることが多い。画像を入力し、物体の位置をBounding Boxとして出力させる。モデルが外したBBoxを眺めながら、「このモデルは画像の何を見ているんだろう」と考える時間が増えた。

VLMやCNNの内部を完全に理解するのは難しい。入力画像が何層ものニューラルネットワークを通り、最終的に座標やラベルとして出てくる。画像を入れる、モデルが何か出す、評価する、Fine-tuningする、また評価する。この繰り返しをしていると、逆に基本的な疑問が浮かんだ。そもそも「画像から特徴を抽出する」とは何なのか。

そこで少し時代を遡り、古典的な画像処理手法であるガボールフィルタを実際にPythonで実装して試してみた。

ガボールフィルタとは何か

ガボールフィルタは、一言で言えば特定の向きと、特定の細かさを持つ模様に反応するフィルタだ。画像の中から縦方向の線、横方向の線、45度方向の線、細かい縞模様、粗い縞模様といった特徴を取り出せる。

最初は「エッジ検出と何が違うのか」と思った。Sobelフィルタでも縦や横のエッジは取れる。違いは、ガボールフィルタには「周波数」という考え方が入っている点だ。線の向きだけでなく、線の間隔も見る。

||||||||||||||||||

という細かい縞模様と、

|     |     |     |

という粗い縞模様は、同じ縦線でも模様としては別物である。ガボールフィルタは、この違いを捉えられる。

数式は構造だけ見れば単純

ガボールフィルタは、ざっくり言えばガウス関数 × cos波でできている。

g(x,y)=exp(x2+γ2y22σ2)cos(2πxλ+ψ)g(x, y) = \exp\left(-\frac{x'^2 + \gamma^2 y'^2}{2\sigma^2}\right) \cos\left(2\pi \frac{x'}{\lambda} + \psi\right)

前半はガウス関数で、中央を強く見て遠くはあまり見ないための部分。

exp(x2+γ2y22σ2)\exp\left(-\frac{x'^2 + \gamma^2 y'^2}{2\sigma^2}\right)

後半はcosで、縞模様そのものを作っている部分だ。

cos(2πxλ+ψ)\cos\left(2\pi \frac{x'}{\lambda} + \psi\right)

つまりガボールフィルタは、縞模様を作って、その縞模様を画像の局所領域に当てているものと考えると分かりやすい。

カーネルを実際に見てみる

PythonとOpenCVを使う。OpenCVには getGaborKernel が用意されており、カーネルサイズ、σ、方向、波長、アスペクト比、位相などを指定してガボールカーネルを生成できる。

import cv2
import numpy as np
import matplotlib.pyplot as plt

kernel = cv2.getGaborKernel(
    ksize=(31, 31),
    sigma=4.0,
    theta=0,
    lambd=10.0,
    gamma=0.5,
    psi=0
)

plt.figure(figsize=(5, 5))
plt.imshow(kernel, cmap="gray")
plt.title("Gabor Kernel")
plt.axis("off")
plt.show()

実行すると縞模様のようなフィルタが現れる。ここで重要なのが theta で、この値を変えるとフィルタの向きが変わる。

thetas = [0, np.pi / 4, np.pi / 2, 3 * np.pi / 4]

for theta in thetas:
    kernel = cv2.getGaborKernel(
        ksize=(31, 31),
        sigma=4.0,
        theta=theta,
        lambd=10.0,
        gamma=0.5,
        psi=0
    )

    plt.figure(figsize=(4, 4))
    plt.imshow(kernel, cmap="gray")
    plt.title(f"theta = {np.degrees(theta):.0f}°")
    plt.axis("off")
    plt.show()

thetaを0度、45度、90度、135度に変えたときのガボールカーネル4枚。縞模様の向きが実際に回転している

0度、45度、90度、135度でフィルタが回転することは確認できた。ここからは、本当に特定方向の線だけに反応するのかを、人工的な画像で検証していく。

実験1:縦線と横線を戦わせる

左半分に縦線、右半分に横線を配置した人工画像を作る。

image = np.zeros((256, 256), dtype=np.uint8)

# 左半分:縦線
for x in range(0, 128, 16):
    image[:, x:x+4] = 255

# 右半分:横線
for y in range(0, 256, 16):
    image[y:y+4, 128:] = 255

左半分が縦縞、右半分が横縞になっているテスト用の人工画像

この画像に、方向の異なるガボールフィルタを適用する。OpenCVの filter2D は画像とカーネルの畳み込み処理に使える。

results = []

for theta in thetas:
    kernel = cv2.getGaborKernel(
        ksize=(31, 31),
        sigma=4.0,
        theta=theta,
        lambd=16.0,
        gamma=0.5,
        psi=0
    )

    filtered = cv2.filter2D(
        image.astype(np.float32),
        cv2.CV_32F,
        kernel
    )

    results.append(filtered)

theta=0°(縦方向を見るフィルタ)とtheta=90°(横方向を見るフィルタ)を適用した結果を並べる。

theta=0度のフィルタを適用した結果。左側の縦縞領域だけが白く強く反応している

theta=90度のフィルタを適用した結果。今度は右側の横縞領域だけが強く反応している

フィルタの方向によって、左と右で明らかに反応が違う。ガボールフィルタは本当に「この向きの模様があります」という特徴量を作っている。

数字で確認する

画像を見るだけでは判断材料として弱いので、左半分(縦縞領域)と右半分(横縞領域)の平均応答を数値で計算する。

for theta, filtered in zip(thetas, results):
    response = np.abs(filtered)

    left_score = response[:, :128].mean()
    right_score = response[:, 128:].mean()

    print(
        f"theta={np.degrees(theta):.0f}° "
        f"left={left_score:.2f} "
        f"right={right_score:.2f}"
    )

実際に実行した結果は次の通り。

theta=0°   left=7009.15  right=3546.58
theta=45°  left=3840.96  right=3738.37
theta=90°  left=3590.15  right=6872.93
theta=135° left=3832.89  right=3731.03

theta=0度(縦方向)では縦縞領域(left)の応答が7009.15、横縞領域(right)が3546.58と約2倍の差が出た。theta=90度(横方向)では逆転し、横縞領域(right)が6872.93、縦縞領域(left)が3590.15になる。

一方、theta=45度と135度はどちらの領域も3800前後で、はっきりした差が出ない。この画像には45度・135度方向の縞模様が存在しないため、どちらの領域に対しても中途半端な反応しか返らないのは妥当な結果だ。

実験2:線の間隔を変えるとどうなるか

ガボールフィルタは向きだけでなく、波長 lambda(どのくらいの間隔の模様を見るか)も指定できる。今度は縦線の向きを固定し、間隔だけを変えた画像を作る。左に細かい縦線、右に粗い縦線を配置する。

image = np.zeros((256, 256), dtype=np.uint8)

# 左:細かい縦線
for x in range(0, 128, 8):
    image[:, x:x+2] = 255

# 右:粗い縦線
for x in range(128, 256, 24):
    image[:, x:x+4] = 255

左半分が細かい縦縞、右半分が粗い縦縞になっているテスト用の人工画像

lambd を変えながら同じように平均応答を計算する。

lambdas = [4, 8, 16, 32]

for lambd in lambdas:
    kernel = cv2.getGaborKernel(
        ksize=(31, 31),
        sigma=4.0,
        theta=0,
        lambd=lambd,
        gamma=0.5,
        psi=0
    )

    filtered = cv2.filter2D(
        image.astype(np.float32),
        cv2.CV_32F,
        kernel
    )

    response = np.abs(filtered)

    left_score = response[:, :128].mean()
    right_score = response[:, 128:].mean()

    print(f"lambda={lambd}: fine={left_score:.2f}, coarse={right_score:.2f}")

結果は次の通り。

lambda=4:  fine=5896.48, coarse=814.31
lambda=8:  fine=7373.16, coarse=3733.12
lambda=16: fine=3596.54, coarse=4992.03
lambda=32: fine=9128.93, coarse=6575.06

lambda=4(短い波長)では細かい縞(fine)が5896.48、粗い縞(coarse)が814.31と約7倍の差がつき、波長が短いフィルタは間隔の狭い縞模様にきれいにハマる。lambda=16になると逆転し、粗い縞(coarse)が4992.03、細かい縞(fine)が3596.54と粗い方が優勢になる。ここまでは想定通りの挙動だ。

ただしlambda=32では、fine=9128.93、coarse=6575.06と細かい縞の方が再び高くなり、単調な傾向が崩れた。これはおそらく、カーネルサイズを ksize=(31, 31) に固定していることが原因である。波長32の縞模様は31×31という小さな窓の中に半周期程度しか収まらず、フィルタが本来見たい「粗い縞」の1周期分を捉えきれていない。結果として単純なガウスのぼかしに近い挙動になり、直感に反する応答が出たと考えられる。カーネルサイズと波長の関係を無視すると、こういう結果が出るという一つの実例だ。

波長を変えることで反応する模様は変わるが、それはカーネルサイズという「見える範囲」の制約の中での話であり、その制約を超えると破綻する。この実験を通して、ガボールフィルタが「方向×周波数を見るフィルタ」であるという説明が具体的に理解できた。

ガボールフィルタを複数並べる

方向をどれか一つに絞る必要はなく、複数の方向・波長の組み合わせを並べて使うのが一般的だ。方向4種類×波長4種類を組み合わせると、16枚の特徴マップが得られる。

gabor_features = []

for theta in thetas:
    for lambd in lambdas:
        kernel = cv2.getGaborKernel(
            ksize=(31, 31),
            sigma=4.0,
            theta=theta,
            lambd=lambd,
            gamma=0.5,
            psi=0
        )

        filtered = cv2.filter2D(
            image.astype(np.float32),
            cv2.CV_32F,
            kernel
        )

        gabor_features.append(filtered)

実際に16通り全てを、最初の縦縞/横縞画像に適用した結果が次の図だ。

方向4種類×波長4種類、計16枚のガボール特徴マップを並べたグリッド。それぞれ違う向き・粗さの模様に反応している

ある特徴マップは縦方向の細かい模様に反応し、別の特徴マップは45度方向の粗い模様に、また別の特徴マップは横方向に反応する。この並びを見て、CNNの畳み込み層に近い構造だと感じた。

CNNの畳み込みとの違い

CNNでも複数のフィルタを画像に適用する。例えば最初の畳み込み層で64個のカーネルを使えば、64枚の特徴マップが作られる。違いは、ガボールフィルタは人間が theta = np.pi / 4 lambd = 8 のように向きと波長を設計するのに対し、CNNはフィルタの値そのものを、損失が小さくなるようにBackpropagationで学習する点にある。

CNNの畳み込み自体はシンプルで、小さな行列を画像の上で滑らせ、掛け算して足すだけの処理だ。ただし、そのフィルタを人間が設計するのではなくデータから学習する。ガボールフィルタを実際に動かしてみたことで、「CNNが特徴抽出を学習する」という説明を、具体的なイメージとして理解しやすくなった。

VLMを評価しながら気づいたこと

最近はVLMに画像を渡せば「この部品のBBoxを出力してください」といったタスクまでこなせる。出力されたBBoxとGTからIoUを計算し、Precision・Recall・F1を出す。精度が悪ければFine-tuningを検討し、LoRAを試し、SFTの設計や座標のトークン化を考える。

ただ、その手前にある「画像から特徴を取り出す」という処理については、意識する機会がほとんどなくなっていた。今から画像認識モデルを設計するときに、ガボールフィルタを起点にするケースは昔より少ないだろう。それでも、自分でフィルタを実装し、角度と波長を変えて反応を数値で確認する作業を通して、モデルが画像を見るとはどういうことかを、少し具体的にイメージできるようになった。

まとめ

ガボールフィルタは、特定の方向と特定の細かさを持つ模様に反応するフィルタである。31×31の小さな行列を画像上で動かすだけで、縦線と横線への反応が7009.15対3546.58というレベルではっきり変わることを確認できた。波長を変えれば細かい縞と粗い縞への反応も変わるが、波長がカーネルサイズに対して大きすぎると直感に反する結果が出ることも分かった。

そして、この方向×波長の組み合わせを大量に並べたとき、CNNの畳み込み層に近い構造が見えてくる。VLMの評価やFine-tuningを繰り返す中で、こうした古典的な手法に立ち返って手を動かしてみると、モデルの入り口で何が起きているのかを再確認できた。