🐟

画像×視線データの可視化(個別および複数データの解析)

に公開

📌はじめに

前回は、1人分の視線データを使って、画像の閲覧順序や滞在時間を可視化しました。

今回は複数ユーザーの視線位置情報を統合し、1枚の画像上にヒートマップとして重ねて表示する方法と、さらに個々のユーザーごとのヒートマップを作成する方法を紹介します。


全員の視線を統合したヒートマップ

ユーザーIDごとのヒートマップ


📌使用するデータ

  • 視線データ : 複数ユーザー分で、各ユーザーごとにファイルが分かれています。視線の位置と注目時間が記録されています(例:サンプル_ID.csv)。
  • 広告画像 : 調査対象の画像(fish.png)、サイズは 1360×840 ピクセル。
データ内容の詳細
  • 各CSVには、position_x, position_y, time などの列が含まれています
  • 1人分のデータ構造やサンプル値は前回の記事で詳しく解説しています

📌ディレクトリ構成

project_root/
├── 1_flow/
│ ├── generate_heatmaps.py # 本コード
│ └── fish.png # サンプル画像
├── 2_data/ # ユーザーの視線CSVファイル
├── 3_output/
│ ├── 全体画像 # 全ユーザーをまとめたヒートマップが保存される
│ └── per_user/ # 各ユーザー別のヒートマップが保存され


📌環境

python3.x


📌コード(generate_heatmaps.py)

#============================================
# 0. ライブラリ
#============================================
import glob
import os
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
import matplotlib.colors as mcolors
import japanize_matplotlib
from scipy.stats import gaussian_kde
from PIL import Image

#============================================
#  1.パラメータ
#============================================
DATA_DIR = "../2_data"
OUTPUT_DIR = "../3_output"
IMAGE_PATH = "fish.png"
BW_METHOD = 0.3      # KDEのスムージング幅
GRID_SCALE = 0.25    # 高速化用グリッド縮小率(1=フル解像度、0.5=半分)

#============================================
#  2.画像読み込み、デレクトリ作成
#============================================
# 画像
img = mpimg.imread(IMAGE_PATH)
img_height, img_width = img.shape[:2]

# 出力ディレクトリ作成
os.makedirs(OUTPUT_DIR, exist_ok=True)
os.makedirs(os.path.join(OUTPUT_DIR, "per_user"), exist_ok=True)
#============================================
# 3. CSVファイル結合
#============================================
csv_files = glob.glob(os.path.join(DATA_DIR, "*.csv"))
df_list = [pd.read_csv(f, encoding='utf-8') for f in csv_files]
df = pd.concat(df_list, ignore_index=True)
print(f"読み込んだデータ件数: {len(df)}")

#============================================
# 4. KDE計算用グリッド座標の作成
#============================================
grid_w = int(img_width * GRID_SCALE)
grid_h = int(img_height * GRID_SCALE)
x_grid, y_grid = np.meshgrid(np.arange(grid_w), np.arange(grid_h))
grid_coords = np.vstack([x_grid.ravel(), y_grid.ravel()])

0. ライブラリ

  • glob, os: ファイル操作やディレクトリ作成に使用
  • pandas, numpy: CSV読み込み・データ結合・数値計算
  • matplotlib, PIL: 画像読み込みやヒートマップ描画
  • scipy.stats.gaussian_kde: KDE(カーネル密度推定)によるぼかし処理
  • japanize_matplotlib: 日本語対応

1. パラメータ

  • BW_METHOD: KDEのぼかし幅(0.3で適度な滑らかさ)
  • GRID_SCALE: 画像縮小率(1=フルサイズ, 0.25=縦横4分の1)
❓KDEのぼかし幅って何❓

視線データは少しだけ動いたり、測定の誤差で「ぶれ」が生じやすいです。
そのままだと細かい「ざわざわ」が多くて、見づらいヒートマップになってしまいます。
そこで**KDE(カーネル密度推定)**という方法で「視線の集まり」をなめらかにぼかして表現します。
このときの「ぼかしの強さ」を決めるのがBW_METHODというパラメータで、
今回の0.3は適度にぼかしてノイズを抑えつつ、注目しているエリアをはっきり見せるための値です。
数値を変えることで、ぼかしの強さを調整できます。


2. 画像読み込みと出力ディレクトリ作成

  • mpimg.imread(IMAGE_PATH): 調査対象の画像を読み込む
  • os.makedirs(): ヒートマップ保存用のフォルダを作る(存在しない場合のみ)

3. 複数CSVファイルの読み込み・結合

複数ユーザーの視線データをまとめて扱えるようにします。

  • glob.glob(): フォルダ内のCSVを全部取得
  • pandas.read_csv(): 順番に読み込む
  • pandas.concat():1つのDataFrameにまとめる

4. KDE計算用グリッド座標の作成

  • GRID_SCALE=0.25: 画像を縮小して計算を軽くします(縦横4分の1くらい)
❓なぜ縮小するの❓

もともとの画像はとても大きいので、そのままだと計算に時間がかかります。
そこで、画像のサイズを「縮小」してグリッド(格子)上で計算を行うことで、処理を早くしています。
GRID_SCALE = 0.25という設定は、縦と横をそれぞれ4分の1のサイズに縮めるという意味です。
たとえば元画像が1360×840ピクセルなら、縮小後は約340×210ピクセルになります。
縮小することで計算するポイントが減るので、処理が速くなります。
しかも、小さくしても見た目の違いはほとんど気にならない程度です。

  • np.meshgrid(): グリッド座標を作成
  • np.vstack(): 二次元配列にまとめ、KDE計算の基準点として使用
❓二次元配列とは❓

画像を縮小したサイズ(横grid_w × 縦grid_h)の格子(グリッド)の各マス目に対して
視線の密度を計算していきます。
np.meshgrid()は、グリッドの全てのマスのX座標の一覧とY座標の一覧を作る関数です。
そしてnp.vstack()でそれらの座標を「まとめて二次元配列」にしています。
この座標配列を使って、視線データのどの位置にどれだけ注目が集まっているかを推定するための計算を行います。
この配列が基準点(計算地点) となります。


#============================================
# 5. スケーリング関数(座標系変換)
#============================================
def scale_positions(df_subset):
    return np.vstack([
        df_subset['position_x'] * GRID_SCALE,
        df_subset['position_y'] * GRID_SCALE
    ])
#============================================
# 6. KDEによる密度推定とヒートマップ作成関数
#============================================
def make_heatmap_and_save(positions, weights, output_path, title):
    kde = gaussian_kde(positions, weights=weights, bw_method=BW_METHOD)
    density = kde(grid_coords).reshape((grid_h, grid_w))
    density /= density.max()

    # PILで拡大して元画像サイズに戻す
    density_img = Image.fromarray((density * 255).astype(np.uint8))
    density_img = density_img.resize((img_width, img_height), Image.BILINEAR)
    density = np.array(density_img) / 255.0

    # 元のカラーマップ取得
    cmap = plt.cm.jet
    
    # RGBA値を取得し、透明度(アルファ)を0から1へ線形変化させる
    colors = cmap(np.arange(cmap.N))
    colors[:, -1] = np.linspace(0, 1, cmap.N)
    
    # 透明度付きの新カラーマップ作成
    transparent_cmap = mcolors.ListedColormap(colors)    

    # 描画
    plt.figure(figsize=(10, 6))
    plt.imshow(img)
    plt.imshow(density, cmap=transparent_cmap, alpha=1)
    plt.colorbar(label='視線密度')
    plt.title(title)
    plt.axis('off')
    plt.savefig(output_path, bbox_inches='tight', pad_inches=0)
    plt.close()

5. スケーリング関数(座標系変換)

画像を縮小して計算するため、視線座標も同じ縮尺に合わせます。

  • scale_positions(df_subset): 視線座標を縮小グリッドに合わせる
  • 入力: df_subset(ユーザーの視線データ)
  • 出力: 縮小後の二次元座標配列(KDE計算用)
❓なぜ座標を縮小するの❓

元の画像はピクセル数が大きいため、そのまま計算すると処理が重くなります。
そこで、計算を高速化するために画像を縮小します。

同じ理由で、視線の位置データも縮小後の画像サイズに合わせる必要があります。
この変換は scale_positions() 関数で GRID_SCALE を掛けて行います。
こうすることで、縮小した画像でも正しい場所に視線をプロットできるようになります。


6. KDEによる密度推定とヒートマップ作成関数

全体ヒートマップ個人別ヒートマップの両方に使用します。

  • make_heatmap_and_save():
    KDEで視線の集まりをなめらかにしてヒートマップを作り、画像として保存する関数
  • positions: 縮小後の視線座標(2×N配列)
  • weights: 各視線点の滞在時間(長く見た場所ほど密度が高くなる)
❓weightsは何してるの❓

weights は、各視線点が「どれくらい注目されたか」を反映するための値です。

今回は time 列(滞在時間)を使っています。

  • 滞在時間が長い点ほど、KDE計算での影響が大きくなります
  • その結果、長く注目された場所はヒートマップ上で濃く、短時間しか見られなかった場所は薄く表示されます

こうすることで、視線の「注目度」をヒートマップに反映できます。

  • gaussian_kde: 視線座標の密度を計算
  • bw_method: ぼかしの強さを調整
  • density /= density.max(): 最大値で割って正規化し、見やすくする
❓なぜ正規化するの❓

KDEで計算した密度を最大値で割ると、ユーザー数や滞在時間の違いに影響されずに
ヒートマップの濃さを比較できるようになります。

これにより、全体ヒートマップと個人別ヒートマップを同じ基準で表示でき、
どの場所が注目されやすいかをわかりやすく確認できます。

  • PIL.Image.resize: 縮小グリッドから元画像サイズに補間して拡大
  • transparent_cmap: 元画像に重ねるため、密度の低い部分は透明に調整
❓なぜ透明にするの❓

ヒートマップを重ねても背景画像が見えるようにするためです。
透明度が低いと、元の画像が見えなくなってしまうのを防ぎます。


#============================================
# 7. 全体ヒートマップ
#===========================================
print("全体ヒートマップ作成中...")
positions_all = scale_positions(df)
weights_all = df['time'].values
make_heatmap_and_save(positions_all, weights_all,
                      os.path.join(OUTPUT_DIR, "all_users_heatmap.png"),
                      "全体ヒートマップ")
#===========================================
# 8. 個人別ヒートマップ
#===========================================
print("個人別ヒートマップ作成中...")
for user_id, group in df.groupby('id'):
    positions = scale_positions(group)
    weights = group['time'].values
    output_path = os.path.join(OUTPUT_DIR, "per_user", f"{user_id}.png")
    make_heatmap_and_save(positions, weights, output_path, f"ID: {user_id}")

print("完了しました!")

7. 全体ヒートマップ作成

全ユーザーの視線データをまとめて、1枚の画像上にヒートマップとして表示します。

  • positions_all = scale_positions(df): 全ユーザーの座標を縮小グリッド用に変換
  • weights_all = df['time'].values: 各視線点の滞在時間を重みとして利用
  • make_heatmap_and_save(...)でヒートマップを作成し、指定パスに保存
❓全体ヒートマップの特徴
  • 全ユーザーの注目が重なる場所が濃く表示される
  • 広告全体での「どの部分がよく見られているか」を直感的に把握できる

8. 個人別ヒートマップ作成

ユーザーごとに視線データを分け、それぞれヒートマップを作成

  • df.groupby('id'): ユーザーIDごとにデータを抽出
  • positions = scale_positions(group): 個別ユーザーの座標を縮小グリッド用に変換
  • weights = group['time'].values: 個別ユーザーの滞在時間を反映
  • make_heatmap_and_save(...)で各ユーザーのヒートマップを作成し、per_userフォルダに保存
❓個人別ヒートマップの活用
  • ユーザーごとの注目傾向を把握できる
  • 全体ヒートマップでは見えにくい個人差やユニークな視線パターンを確認できる

📌まとめ

今回はPythonを使って、複数ユーザーの視線データを1枚の広告画像に重ねて、
みんながどこをよく見ているかをわかりやすく可視化する方法を紹介しました。

  • 全体ヒートマップ
    全ユーザーの視線をまとめたヒートマップを見ると、
    広告全体でどの部分が注目されやすいかがひと目でわかります。
    注目度の高いエリアを把握して、デザイン改善やマーケティング施策に活かせます。

  • 個人別ヒートマップ
    ユーザーごとのヒートマップを作ることで、
    注目ポイントの個人差や共通点を比較できます。
    特定のグループが興味を持つ傾向や、個々の視線パターンも確認できるので、
    より細かい分析に役立ちます。

これらを組み合わせることで、広告やコンテンツの改善に向けた具体的な洞察を得やすくなります。


参考リンク・素材について

画像素材

掲載している画像素材は「いらすとや」さんのものを加工して使っています。

GitHubリポジトリ

本記事で紹介したコードやサンプルデータはこちらのリポジトリで公開しています。
 https://github.com/iwakazusuwa/gaze-heatmap-generator-mit-license

Discussion