顔画像で試す画像特徴ベクトルの足し算引き算

概要

実験結果のファイルをGitHubに保存しています

概要

下記のイベントレポートを読んで、メルカリの荒瀬さんの発表中の画像データの特徴ベクトルの足し算引き算がおもしろかったので実験
メルカリ・ヤフー・ZOZO開発者が語る「画像検索」の最前線!  Bonfire Data & Science #1 イベントレポート
メルカリにおける写真検索機能(スライド)
Closing the Gap Between Query and Database through Query
Feature Transformation in C2C e-Commerce Visual Search(論文)

特徴変換の概念図(スライドより引用)

image.png

実験概要

複数人物の表情が含まれるの画像データセットを用いて、
人物 + 表情 = (人物, 表情)
の計算が可能かを実験する。

image.png

データ

The Japanese Female Facial Expression (JAFFE) Database
Coding Facial Expressions with Gabor Wavelets(論文)

image.png

女性10人の表情7つが入ったデータセット。

人物

['KR', 'NM', 'KM', 'NA', 'KA', 'TM', 'UY', 'MK', 'YM', 'KL']

表情

# FEAR(怖れ)、HAPPY(喜び)、SUPRISE(驚き)
# ANGRY(怒り)、NEUTRAL(無表情)、DISGUST(嫌悪)、SADNESS(悲しみ)
['FE', 'HA', 'SU', 'AN', 'NE', 'DI', 'SA']

(KRさん, 怖)、(KRさん、怒)、(KRさん、喜)、(NMさん、怖)、(NMさん、怒)...という特徴ベクトルから、特徴変換の演算によって
KRさんの特徴ベクトル、NMさんの特徴ベクトル、"怖れ"の特徴ベクトル、"怒り"の特徴ベクトルを取り出すのが目標

損失関数

今回は、TripletLossを利用して、
明示的に同一人物・同表情は近く、別人物・別表情は遠くにする学習を行う
-> 画像データが多くあれば、暗黙的にも学習が可能?

Deep Metric Learning の定番⁈ Triplet Lossを徹底解説(解説記事)

class TripletLoss(nn.Module):
    def __init__(self, margin=1.0):
      super(TripletLoss, self).__init__()
      self.margin = margin

    def forward(self, x0, x1, x2):
      # x0に対して、x1は同じ人物or同じ表情
      # x0に対して、x2は異なる人物or異なる表情
      # x0とx1のユークリッド距離を計算
      d1 = torch.sqrt(torch.sum(torch.pow(x0 - x1, 2), 1))
      # x0とx2のユークリッド距離を計算
      d2 = torch.sqrt(torch.sum(torch.pow(x0 - x2, 2), 1))
      # d1 - d2 < 0 .. つまり、d1 < d2 .. x0とx1(同じもの)は近い、x0とx2(異なるもの)は遠い なら損失は0 -> 現状維持
      # d1 - d2 > 0 .. つまり、d1 > d2 .. x0とx1は遠い、x0とx2は近い なら損失が大きくなる -> 最適化される
      loss = torch.clamp(d1 - d2 + self.margin, min=0.0)
      return torch.sum(loss) / x0.size()[0]

モデル

論文どおり、mobilenet_v2を利用する

import torch.nn as nn
from cnn_finetune import make_model

class Net(nn.Module):
    def __init__(self, C):
      super(Net, self).__init__()
      self.nn = make_model('mobilenet_v2', num_classes=C, pretrained=True, input_size=(256, 256))

    def forward(self, X):
      return self.nn(X)

データローダ

TripletLossを適用するために、同じ人物または同じ表情の画像と、異なる人物または異なる表情の画像の3枚1セットの画像を出力する

%matplotlib inline
import matplotlib.pyplot as plt
from glob import glob
from PIL import Image
import torch
from torchvision import transforms
import numpy as np
import pandas as pd

class FaceDataset(torch.utils.data.Dataset):
    def __init__(self, files, transform):
      self.transform = transform
      self.df = pd.DataFrame({
          # ファイル名から人物と表情のラベルを取り出す
          # 人物のラベル 例) KM.HA1.4.tiff -> KM
          'name': list(map(lambda x: x.split('/')[-1].split('.')[0], files)),
          # 表情のラベル 例) KM.HA1.4.tiff -> HA
          'face': list(map(lambda x: x.split('/')[-1].split('.')[1][:-1], files)),
          # ファイル名
          'path': files
      })      
      # 同じ人物表情について重複があるので、ユニークにする。
      self.df = self.df.groupby(['face', 'name']).head(1)


    def __len__(self):
      # データの長さ
      return len(self.df.index)

    def __getitem__(self, idx):
      # idxの位置のデータ行を取り出す
      row = self.df.iloc[idx]
      # ファイルパス
      path = row['path']
      # ファイルパスから画像データを読み取り
      img = self.open_image(path)

      # 今回は明示的に人物と表情の距離を分ける
      # 50:50の確率で人物と表情の組み合わせとで学習を分ける
      if np.random.random() < 0.5:
        # 人物
        # 人物名を取り出す
        name = row['name']
        # 同じ人物の別表情画像
        path_same = self.df.pipe(lambda df: df[df['name'] == name]).sample(1)['path'].tolist()[0]
        img_same = self.open_image(path_same)
        # 別人物の画像
        path_other = self.df.pipe(lambda df: df[df['name'] != name]).sample(1)['path'].tolist()[0]
        img_other = self.open_image(path_other)
      else:
        # 表情
        # 表情名を取り出す
        face = row['face']
        # 同じ表情の別人物画像
        path_same = self.df.pipe(lambda df: df[df['face'] == face]).sample(1)['path'].tolist()[0]
        img_same = self.open_image(path_same)
        # 異なる表情の画像
        path_other = self.df.pipe(lambda df: df[df['face'] != face]).sample(1)['path'].tolist()[0]
        img_other = self.open_image(path_other)

      # 個々の画像についてtransformで画像を変形して結果を返す
      return self.transform(img), self.transform(img_same), self.transform(img_other), path

    def open_image(self, path):
      return Image.open(path).convert('RGB')

# 画像を変形する処理
transform = transforms.Compose([
    # ランダムにサイズ変更
    transforms.RandomResizedCrop((256, 256), scale=(0.75, 1.0)),
    # ランダムに色合いを変更
    transforms.ColorJitter(brightness=0.3, contrast=0.5, saturation=0.5, hue=0),
    # ランダムに水平反転
    transforms.RandomHorizontalFlip(0.5),
    # ランダムにアフィン・シーア変換
    transforms.RandomAffine(0.5, shear=0.5),
    # pytorchテンソルに変換
    transforms.ToTensor()
])


# wget https://zenodo.org/record/3451524/files/jaffedbase.zip
# unzip jaffedbase.zip
# データセットの画像ファイルを読み込み
files = glob('jaffedbase/*.tiff')

dataset = FaceDataset(files, transform)
dataloader = torch.utils.data.DataLoader(dataset, batch_size=16, shuffle=True)


# 出力の確認
def plot(X):
  plt.imshow(np.transpose(X, (1, 2, 0)))
  plt.show()

for X0, _, _, path in dataloader:
  plot(X0[0].numpy())
  break

学習

import torch.optim as optim
import torch.nn.functional as F
from sklearn.metrics import confusion_matrix

# Cは任意の数、今回は人物・表情のパターン分とする
C = dataset.df['face'].nunique() * dataset.df['name'].nunique()

# cpu/cudaを指定
device = torch.device('cuda')
# モデルを生成
model = Net(C)
model = model.to(device)
# 損失関数
criterion = TripletLoss()
# 最適化関数
optimizer = optim.Adam(model.parameters(), lr=0.0001)

EPOCH = 50
for idx in range(EPOCH):
  model.train()
  all_loss = 0.0
  for X0, X1, X2, path in dataloader:
    # テンソルの型をdeviceに合わせて変換
    X0 = X0.to(device)
    X1 = X1.to(device)
    X2 = X2.to(device)

    # 損失計算
    loss = criterion(model(X0), model(X1), model(X2))

    # 勾配計算・学習
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

    all_loss += loss.item()
  print(all_loss)

近傍探索

特徴変換

論文中ではL2ノルムが1の非負のベクトルで演算を行っているので、
それに習い、ベクトル変換を行うメソッドを用意する

def norm(ary):
  # 非負ベクトルに変換する
  func = lambda x: (x - x.min()) / x.max()
  if len(ary.shape) > 1:
    return np.array([func(row) for row in np.array(ary)])
  else:
    return func(np.array(ary))

def l2norm(ary):
  # ベクトルをベクトルのL2ノルム(ユークリッド距離)で割ることで、ユークリッド距離が1のベクトルに変換する
  def underto0(ary):
    # 負は0に置き換える
    ary[ary < 0] = 0
    return ary

  func = lambda x: x / np.linalg.norm(x, ord=2)
  if len(ary.shape) > 1:
    return np.array([func(underto0(row)) for row in np.array(ary)])
  else:
    return func(underto0(np.array(ary)))

近傍探索

ベクトル同士の距離を高速に測り類似画像を出力するために、近傍探索ツールを利用する。
今回はYahooJapanのNGTを利用する。
NGT

検索対象の特徴ベクトルの作成と保存

import ngtpy

# 評価用の画像は変形を行わない
transform = transforms.Compose([
  # 画像を任意のサイズにサイズ変更
  transforms.Resize((256, 256)),
  # torchのテンソル型に変換
  transforms.ToTensor()
])
# 画像変形を行わない評価用のデータセット
eval_dataset = FaceDataset(files, transform)
# バッチサイズは1にして画像を1つずつ取り出す
eval_dataloader = torch.utils.data.DataLoader(eval_dataset, batch_size=1, shuffle=False)

vectors = []
files = []
for X, _, _, path in eval_dataloader:
  X = X.to(device)
  # mobilenet_v2のGAP層のベクトルを取り出す。その際に、axis=2方向にmedianを行い値をつぶす。
  # =>不要かも?
  a_vector = np.median(np.median(model.nn._features(X).cpu().tolist(), axis=2), axis=2)[0].tolist()
  vectors.append(a_vector)
  files.append(path[0])

# L2ノルムが1の非負のベクトルに変換する
features = l2norm(norm(np.array(vectors)))

# 近傍探索ライブラリにベクトルを保管する
dim = features.shape[1]
ngtpy.create("images", dim)
index = ngtpy.Index("images")
index.batch_insert(features)
index.save()

検索クエリ特徴ベクトルの作成

from PIL import Image
import random

class Feature:
  # 特徴量を作り出すクラス
  def __init__(self, features, df_dataset):
    # 非負かつL2ノルム=1に変換済みの特徴ベクトル
    self.features = features
    # ファイルパスと、人物・表情ラベルがついたpandasデータフレーム
    # => データローダのを使う
    self.df_dataset = df_dataset
    self.files = df_dataset['path']

  def get_feature_face(self, face):
    # 指定した表情の特徴ベクトルを取り出す。
    if face in self.df_dataset['face'].unique():
      # 指定された表情の特徴ベクトルだけをフィルタリングで取り出す。
      # 特徴ベクトルの中央値を非負L2ノルム1変換をかけたベクトルを、その表情の特徴ベクトルとする
      return l2norm(np.median(self.features[self.df_dataset['face'] == face], axis=0))
    else:
      raise Exception('存在しない表情です:', face)

  def get_feature_name(self, name):
    # 指定した人物の特徴ベクトルを取り出す。
    if name in self.df_dataset['name'].unique():
      # 指定された人物の特徴ベクトルだけをフィルタリングで取り出す。
      # 特徴ベクトルの中央値を非負L2ノルム1変換をかけたベクトルを、その人物の特徴ベクトルとする
      return l2norm(np.median(self.features[self.df_dataset['name'] == name], axis=0))
    else:
      raise Exception('存在しない名前です:', name)

  def plot(self, l_idx):
    # 検索結果を表示するためのメソッド
    def get_concat_h(im1, im2):
      # 画像を横つなぎにする
      dst = Image.new('RGB', (im1.width + im2.width, im1.height))
      dst.paste(im1, (0, 0))
      dst.paste(im2, (im1.width, 0))
      return dst

    idx = l_idx[0]
    img = Image.open(self.files[idx])
    for idx in l_idx[1:]:
      img = get_concat_h(img, Image.open(self.files[idx]))
    display(img)

obj_f = Feature(features, eval_dataset.df.reset_index(drop=True))

NAME = ['KL', 'NM', 'KR', 'KA', 'YM', 'MK', 'UY', 'TM', 'NA', 'KM']
FACE = ['HA', 'NE', 'SU', 'AN', 'FE', 'DI', 'SA']

get_name = lambda x: x.split('/')[-1].split('.')[0]
get_face = lambda x: x.split('/')[-1].split('.')[1][:-1]

N = 100
SUCCESS = 0
for num in range(N):
  # ランダムに人物と表情の識別子を取り出す
  name = random.choice(NAME)
  face = random.choice(FACE)

  # 個々の特徴ベクトルを計算する
  feature_name = obj_f.get_feature_name(name)
  feature_face = obj_f.get_feature_face(face)

  # 人物ベクトルと表情ベクトルを足し合わせる
  search_x = l2norm(feature_name + feature_face)

  # 近傍探索を実施する
  # search_xに最も近い特徴ベクトルが取り出される
  result = index.search(search_x, 1)
  for idx, d in result:
    filename = obj_f.files[idx]
    if get_face(filename) == face and get_name(filename) == name:
      SUCCESS += 1
print('正答率:%f' % SUCCESS / N)

正答率は0.83で、特徴ベクトルを学習できた。

image.png

image.png

image.png

ユーザー登録して、Qiitaをもっと便利に使ってみませんか。
  1. あなたにマッチした記事をお届けします
    ユーザーやタグをフォローすることで、あなたが興味を持つ技術分野の情報をまとめてキャッチアップできます
  2. 便利な情報をあとで効率的に読み返せます
    気に入った記事を「ストック」することで、あとからすぐに検索できます
コメント
この記事にコメントはありません。
あなたもコメントしてみませんか :)
すでにアカウントを持っている方は
ユーザーは見つかりませんでした