虹ヶ咲1stライブ アンコールブレード投票に関する画像認識の観点での考察

 本ブログ初の技術ネタ記事ですが、ほとんど趣味の話です。12/15にラブライブ虹ヶ咲1stライブに参加してきましたが、この時、アンコールを歌うメンバーを会場投票で決めるという演出がありました。この時の流れは以下でした。

  1. 会場にいる「あなた*1」は、持っているブレード*2を歌ってほしいメンバーのカラーに変える。
  2. モニターに会場を俯瞰する動画が流れ、「集計中」という文字が現れる。(時間にして5分もかからなかった印象)
  3. 会場で最も数が多かったであろうメンバーが登場し、アンコールを歌う。

 実際にどのようにカウントしたのか、手法は全くわかりません。もしかしたら、人海戦術でスタッフが直接カウントして対応していた可能性もあります。 ですが、運営側は会場のあらゆるところで取得している動画または画像データを活用できるはずです。このことについて、少し技術的に考察しようというネタです。*3

 注: 本記事は、1stライブの投票結果自体の検証や投票企画に対する物申しといった目的は全くございません。検証のためのデータを揃えられないですし、そもそも検証や物申しができるほど画像認識技術に明るくありません。1stライブ初日のアンコール演出の際、ともりるだけが出てきた驚きに加えて、「どうやって集計したのか」という手法に意識を持っていかれて、アンコールのCHASE!に集中できなかった過去の自分の無念をはらすためのトライアルです。



問題へのアプローチ

 運営が使用したデータとして考えられるのは動画または画像です。動画は扱いにくいので、本記事では画像を用います。達成すべき目標は、 「画像に写ったブレードの色を9人のメンバーのいずれかに対応させ、どの色が最も多いか調べる」になります。コンピュータビジョンの観点では、「ある色に対応したオブジェクトの数を数える」という問題設定になり、「ある色に対応したオブジェクトを作る」ことが最大のハードルになります。

 これについては特別真新しい技術という印象はなく、古典的なやり方、近代的なやり方それぞれで様々なアプローチがあるかと思いますが、今回は「教師となるブレードの画像データから色を表す特徴量を事前に準備し、実画像に対してマッチングさせる」という方針をとります。

 全体を通じて実装はPython(3.6)を、画像処理モジュールはOpenCV(4.1.2.30)を使用します。

ブレードカラーの教師データ取得

 実際に光らせたブレードの写真を取得し、それを教師として扱います。ただし、今回私はライブグッズはパンフレットしか購入しなかったため、虹ヶ咲公式ブレードを所持していません*4。ですので、実験自体はAqoursのブレードを使って行います。ブレードの写真を取得し、コンピュータに読み込ませます。

# モジュール読み込み
import os
import glob

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

# matplotlibの配色用辞書
PLOTCOLERDICT = {"chika": "orange", 
                 "riko": "lightpink", 
                 "kanan": "lawngreen", 
                 "dia": "red", 
                 "you": "blue", 
                 "yoshiko": "grey", 
                 "hanamaru": "yellow",
                 "mary": "violet", 
                 "ruby": "deeppink"}

# パス情報を含めたファイルリスト取得
imagepath = "./image"
blades = glob.glob(imagepath + "/*.JPG")
blades.sort()
print(blades)

# 9枚の画像を表示
fig, axes = plt.subplots(3, 3, figsize=(12, 8))

axes = axes.flatten()

for image, ax in zip(blades, axes):
    blade_bgr_image = cv2.imread(image)
    # OpenCVで読み込んだ画像をmatplotlibで出力するためにBGR->RGB変換
    blade_rgb_image = cv2.cvtColor(blade_bgr_image, cv2.COLOR_BGR2RGB) 
 
   ax.imshow(blade_rgb_image)
   # 余計な軸を消す処理 
   ax.tick_params(labelbottom=False,
                labelleft=False,
                labelright=False,
                labeltop=False,
                bottom=False,
                left=False,
                right=False,
                top=False)
    ax.set_title(image.split("/")[-1])

f:id:kkaries0328:20191222143119p:plain
各メンバーのブレードカラー教師画像

 教師画像は、以下の前提を意識して取得しました。

  • 会場は暗いので、同様のバックグラウンドノイズに近づける為に部屋を暗くして撮影した。
  • 今回は1stライブで、公式ブレードは事前物販・前日物販・当日物販のいずれかで入手されたものが大半と考えられる。ブレードは電池残量が少なくなると発色が変わる傾向があるが、購入から時間が経過していないため、この要因による色の系統誤差は最小化されている可能性が高い。この状態を再現するため、ブレードの電池は新しいものを使用した。*5

色の特徴量化

 それぞれのブレードの色を特徴量として数値化します。色を数値として扱う時に使う数値の集まりのことを「色空間」と呼びます。メジャーなものはRGBでしょうか。これは各ピクセルに赤(Red), 緑(Green), 青(Blue)の三色の強度を割り当てます。

RGB色空間で各ブレードの色を確認

fig, axes = plt.subplots(3, 3, figsize=(12, 8))

axes = axes.flatten()

for image, member, ax in zip(blades, PLOTCOLERDICT.keys(), axes):
    bgr = cv2.imread(image)
    b_array = bgr[:,:,0].flatten()
    g_array = bgr[:,:,1].flatten()
    r_array = bgr[:,:,2].flatten()
    ax.hist(b_array, bins=100, alpha=0.7, label="b", color="b", log=True)
    ax.hist(g_array, bins=100, alpha=0.7, label="g", color="g", log=True)
    ax.hist(r_array, bins=100, alpha=0.7, label="r", color="r", log=True)
    ax.set_title(member)
    ax.legend()

f:id:kkaries0328:20191222121157p:plain
教師画像のRGB抽出

 RGBは最もメジャーな色空間ですが、気をつけなければいけない点は、画像の背景色である「黒」を構成するRGBの情報も上記スペクトルには含まれてしまうということです。純粋にブレードの色を構成するRGB情報だけを取得するために、画像を2値化し、バックグラウンドをマスキングします。

# 2値化し、バックグラウンドをマスキングする。
fig, axes = plt.subplots(1, 2, figsize=(28, 16))
image = blades[4]
bgr = cv2.imread(image)
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
th, maskimage = cv2.threshold(gray, 0, 255, cv2.THRESH_OTSU)
axes[0].imshow(masked_image, cmap="gray")
masked_bgr = cv2.bitwise_and(bgr, bgr, mask=maskimage)
masked_bgr = cv2.cvtColor(masked_bgr, cv2.COLOR_BGR2RGB) 
axes[1].imshow(masked_bgr)

 マスク画像とマスキングの例は以下

f:id:kkaries0328:20191222143311p:plain
マスク画像例(左)とマスキング後の画像例(右)

fig, axes = plt.subplots(3, 3, figsize=(12, 8))

axes = axes.flatten()

for image, member, ax in zip(blades, PLOTCOLERDICT.keys(), axes):
    bgr = cv2.imread(image)
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
    th, maskimage = cv2.threshold(gray, 0, 255, cv2.THRESH_OTSU)
    masked_bgr = cv2.bitwise_and(bgr, bgr, mask=maskimage)
    
    b_array = masked_bgr[:,:,0].flatten()
    g_array = masked_bgr[:,:,1].flatten()
    r_array = masked_bgr[:,:,2].flatten()
    
    # 黒は全て0があてがわれるので、0より大きい値がほしい色に対応するRGB値
    b_array = b_array[(b_array > 0)]
    g_array = g_array[(g_array > 0)]
    r_array = r_array[(r_array > 0)]
    
    ax.hist(b_array, bins=100, alpha=0.7, label="b", color="b", log=True)
    ax.hist(g_array, bins=100, alpha=0.7, label="g", color="g", log=True)
    ax.hist(r_array, bins=100, alpha=0.7, label="r", color="r", log=True)
    ax.set_title(member)
    ax.set_xlim(0, 255)
    ax.legend()

f:id:kkaries0328:20191222121356p:plain
教師画像のRGB抽出(バックグラウンド除去後)

 バックグラウンドの黒を除去したため、0周りに張り付いていた要素が多くのスペクトルから無くなりました。一方で、今度はどの色でも共通して255近傍に値が張り付いています。元画像を見ればわかりますが、発光した物体を撮影すると、その物体自身は白っぽく見えるので、「白」に相当する色を作るRGBの要素が255近傍に混ざっているようです。

 このように、RGBの色空間で物体の色を考えると、RGBのスペクトルに自分が目的としない色の構成成分が混ざるため、色の検出には使いにくいことがわかります。今回は特徴量として不採用とします。

HSV色空間への変換

 RGB以外によく使われる色空間はHSVでしょうか。これは、一ピクセルに「色相(Hue)」、「彩度(Saturation)」、「明度(Value)」の3つの情報を対応付けます。この表記の利点は、彩度と明度は、Hが示す色に対して「白みがかっているか」、「黒みがかっているか」を示すパラメータとなるため、対象の色を直接表すパラメータが色相だけで済む点にあります。すなわち、色相の範囲を指定することで対象の色とそれ以外を分離することができ、「色検出」と呼ばれるアプローチでよく使われる色空間になるようです。

fig, axes = plt.subplots(3, 3, figsize=(12, 8))

axes = axes.flatten()

for image, member, ax in zip(blades, PLOTCOLERDICT.keys(), axes):
    blade_image = cv2.imread(image)
    hsv = cv2.cvtColor(blade_image, cv2.COLOR_BGR2HSV)
    h_array = hsv[:,:,0].flatten()
    #s_array = hsv[:,:,1].flatten()
    #v_array = hsv[:,:,2].flatten()
    ax.hist(h_array, bins=100, alpha=0.7, color=PLOTCOLERDICT[member])
    ax.set_title(member)

f:id:kkaries0328:20191222121729p:plain
HSV変換した教師画像のH抽出(S, Vは省略)

 特に2値化のようなバックグラウンド除去処理を施さなくても、色相の値だけを抽出することで画像内の色の情報を取得できることがわかります。また、今回は1枚の画像に1種類のブレードカラーを対応させている為、各色が対応する色相の値にピークをつくるヒストグラムを観察することができます。これにより、例えば曜ちゃんカラーのブレードを調べたければ、対象画像の100から110くらいまでの色相を抽出すれば良い事になります.

 が、話はそこまで単純にはならなそうです。。まず、花丸カラーのスペクトルについて、他に比べて広い範囲へのばらつきが見られます。支配的なのは20~60くらいの範囲ですが、他に比べて圧倒的にばらつきが大きいです。これに関しては今回取得した教師データのクオリティの問題なのか、花丸カラー特有の問題なのか、原因がよくわかっていません。

 さらに問題なのは、梨子・ルビィ・鞠莉カラー、曜・善子・鞠莉カラーの色相の値がお互いに近いことです。まあ、考えてみれば当たり前で、前者はピンク系統、後者は青系統*6なので、色相としては近い値をとることになります。つまり、これらの色の分離を色相だけで行うのはおそらく困難だということです。

 まあとはいえ、各色相の大まかなレンジが見えたので、ダメ元で画像からの色検出を試してみましょう。

会場の画像から色検出

 虹ヶ咲のライブ会場アンコール時の画像が入手できないことと、そもそも本トライアルはAqoursのブレードを使っている為、Aqoursのライブ画像をお借りして*7検証してみます。

前処理

stageimage = "4th_kimikoko.jpeg"

stageimagepath = os.path.join(imagepath, stageimage)

# cv2で取り込み
bgr_image = cv2.imread(stageimagepath)
# matplotlibで表示するためにBGRをRGBに変換
rgb_image = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB) 


plt.figure(figsize=(12,8))
plt.imshow(rgb_image)

f:id:kkaries0328:20191222143647p:plain
検証対象画像

 このまま使うと、ブレード以外の色がノイズになるので、縦軸方向は230ピクセル以下だけを切り出すことにします。

# ブレード部分だけに着目
clipped_rgb_image = rgb_image[230:333, :,:]
plt.figure(figsize=(12, 8))
plt.imshow(clipped_rgb_image)

f:id:kkaries0328:20191222143728p:plain
検証対象画像(不要部分削除)

 今回の目的はブレードの色分けなので、それ以外の領域はなるべく除去したくなります。ここでは、バックグラウンドの色味のムラ(ブレードに照らされた観客の色など)を最小化する為に、教師データのRGB取得時と同様、2値化処理をかけてマスキングします。2値化は、閾値を手動で決める方法とデータから自動で決める方法(大津の2値化)の2種類がありますが、今回は後者を使います。

# 2値化してバックグラウンドノイズを抑える
clipped_gray_image = cv2.cvtColor(clipped_rgb_image, cv2.COLOR_RGB2GRAY)

# 1. 自分で閾値を決める場合の2値化
# threshold = 80
# th, mask_img = cv2.threshold(clipped_gray_image, threshold, 255, cv2.THRESH_BINARY)

# 2. 大津の2値化(自動閾値決定アルゴリズム)
th, mask_img = cv2.threshold(clipped_gray_image, 0, 255, cv2.THRESH_OTSU)

# 2値化で取得したマスクイメージを使って、元の画像をマスキング
masked_clipped_rgb_image = cv2.bitwise_and(clipped_rgb_image, clipped_rgb_image, mask=mask_img)

f:id:kkaries0328:20191222143851p:plain
検証対象画像のバックグラウンド除去用マスク

f:id:kkaries0328:20191222143926p:plain
検証対象画像(バックグラウンド除去後)

実験例1) 千歌ブレード検出

色相の範囲を指定して特定の色をだけを抽出

 hの値の範囲を絞って、その範囲に該当するピクセル以外をマスキングするマスク画像を作り、それをオリジナルの画像にかけて特定の色だけを取り出します。

# 例) hを1~17の範囲にし、みかん色のブレードの領域を切り出すマスクを作成
masked_clipped_hsv_image = cv2.cvtColor(masked_clipped_rgb_image, cv2.COLOR_RGB2HSV)
dst = cv2.inRange(masked_clipped_hsv_image, (1,0,0), (17, 255, 255))

plt.figure(figsize=(12, 8))
plt.imshow(dst, cmap="gray")

# マスク画像(dst)を元の画像にかけて、みかん色ブレードの領域を抽出する
color_clipped_rgb_image = cv2.bitwise_and(clipped_rgb_image, clipped_rgb_image, mask=dst)

plt.figure(figsize=(12, 8))
plt.imshow(color_clipped_rgb_image)

f:id:kkaries0328:20191222144016p:plain
検証対象画像のみかん色ピクセル抽出マスク

f:id:kkaries0328:20191222144053p:plain
検証対象画像からみかん色ピクセルだけを抽出した画像

連結成分のラベリング

 上記までで、画像から任意の色に該当するピクセル領域を抽出できたので、次はこの画像から1本のブレードオブジェクトを認識する処理を行います。これは、ピクセル的に連結した領域を一つのオブジェクトとしてラベリングするタスクで、OpenCVモジュールのconnectedComponentsで実現できます。*8

result = clipped_rgb_image

num_labels, label_image, stats, center = cv2.connectedComponentsWithStats(dst)

for index in range(num_labels):
    # 一つのラベルを表すバウンディングボックスの始点となる(x, y), w(幅), h(高さ)を取り出す
    x = stats[index][0]
    y = stats[index][1]
    w = stats[index][2]
    h = stats[index][3]

    # バウンディングボックスを描画
    cv2.rectangle(result, (x, y), (x+w, y+h), (255, 0, 255))

plt.figure(figsize=(12, 8))
plt.imshow(result)

f:id:kkaries0328:20191222144212p:plain
みかん色ブレードの検出

   ちょっとわかりにくいかもしれませんが、画像の中にピンク色の四角が現れました。これがバウンディングボックスで、この四角の中のピクセル群を一つのオブジェクトとみなします。つまり、この四角の中に1本のブレードに相当するピクセル群を該当させれば、このオブジェクトの数をブレードの本数と見なすことができます。

 今回、比較的はっきりと写っているみかん色ブレードを正しく一つのオブジェクトとして認識しています。ただし、真ん中あたりの黄色ブレードの一部をみかん色と認識しています。元々黄色は他の色と比べてhの値がばらつく傾向があり、隣接しているみかん色のhの値の領域にも一部侵食していたため、このような誤認識が起こるのでしょう。

 また、それ以外の細かい領域のノイズっぽいピクセルをオブジェクト判定していたりするため、やはり高精度を狙うにはノイズ処理と色の特徴量設計をより深堀する必要がありそうです。

実験例2) 花丸ブレード検出

 hの値を25~60として、黄色を抽出します。

f:id:kkaries0328:20191222144306p:plain
黄色ブレードの検出

 真ん中の花丸ブレードをオブジェクト認識しています。hの値のヒストグラムを思い出すと、今回の範囲である25~60は、他の色に対して混ざりにくいため、比較的誤認率が低そうといえます。ただし、ブレードの色味の問題で、ブレードの下半分しかオブジェクト判定していないものがあります。これはオブジェクト領域の分解能(1本のブレードを1本とカウントできるかどうか)に効いてくる可能性があります。

実験例3) ルビィブレード検出

 hの値を130~170として、ピンク色を抽出します。

f:id:kkaries0328:20191222144336p:plain
ピンク色ブレードの検出

 ピンク色に見えるブレードはちゃんとオブジェクト認識されています。面白いのは、x=200, y=60あたりに見えるみかん色とピンク色の2本のブレードの内、裏側に見えるピンクのブレードだけをちゃんとオブジェクト認識しています。みかん色とピンクは色相の上でもそれなりに離れているので、このあたりの分類はそれなりに高精度にやれるのでしょう。しかし、x=50, y=20あたりの2本のピンク色のブレードは2本重なっているところを一つのオブジェクトとして認識しています。同色が重なると、1つの連結成分扱いされてしまうのでしょう。

 また、当初の懸念の通り、鞠莉のブレードを誤認識しています。また、画像の解像度的に私の目で認識できませんが、梨子のサクラピンクもおそらく誤認識しています。この3色をそれなりの精度で分類しようとするなら、hだけでは難しいということの一例です。

実験例4) 曜ブレード検出

 最後にhの値を100~120として、私の推しである曜ちゃんの色であり、Aqoursのイメージカラーである青色を抽出します。

f:id:kkaries0328:20191222144551p:plain
青色ブレードの検出

 これもうまいこと青っぽいブレードをオブジェクト扱いしています。一部、一つのブレードに2つのオブジェクトがあてがわれてしまっている例があります。これはブレードの色味が薄いせいでマスク画像の上で途中で途切れ、連結成分検出で2つにわかれてしまったのでしょう。このあたりのチューニングも厄介そうです。

 また、これも当初の懸念通り、善子ブレードとの誤認識が起きている可能性があります。画質の問題で、そもそも色の認識を私自身が正しく行えているかどうかにも問題はありますが、右側に白っぽいブレードがあり、これをオブジェクト認識しています。

虹ヶ咲カラーとの比較

 今回ブレードを購入しなかったこと、そもそも手ぶら参加でライトを使わなかったことが災いして、いまだに虹ヶ咲のカラーをちゃんと把握できていません。しかし、ざっくりと以下のような色味かと思っています。

  • 歩夢: ピンク
  • かすみ: 黄色
  • しずく: 薄めの青
  • 愛: オレンジ
  • 璃奈: 白
  • 彼方: 紫
  • エマ: 緑
  • 果林: 濃いめの青
  • せつ菜: 赤

 配色がAqoursとほぼ似ていると考えると、おそらくしずく・果林・璃奈・彼方あたりは現手法では分離がかなり厳しい印象です。

まとめ

 当初の想定通り、みかん色や黄色など、色相が9パターンの中で離れている物は、それなりに色を認識していることがわかりますが、ピンク系統、青系統はやはり色相の1特徴量では分離は厳しそうです。特に、電池の劣化や公式・非公式の違いによるブレードの色味の差によって、これらの類似した色相の近傍の不安定性はさらに大きくなることが予想されます*9。画像の前処理、特徴量設計をもっと真面目にやれということですね。

 とはいえひとまず、基本的なアプローチが確立できたことと、ちゃんとチューニングすればそれなりの分類器ができそうなことがなんとなくわかりました。画像認識系のスタディの良い教材になりそうな気がします。今後時間があれば、各種パラメータチューニング、特徴量設計、定量的な性能評価(正解率、誤認率など)をやってみようかと思います。

 これで心置きなく今後の虹ヶ咲のライブを楽しめそうです。

*1:お客さんのこと

*2:ペンライトのこと

*3:似たような投票は、Aqoursのファンミーティングの時にもあったことを執筆中に思い出しました。この時はたしか、歌う楽曲に対する投票で、4つの曲に対応したカラーにブレードを変えて、その数で歌う曲をオンデマンドで決めるというものでした。当時は、会場スタッフがカウントしているんだろうと勝手に思っていたのですが、もしかしたら当時からなんらかのテクノロジーを使っていたのかもしれません。

*4:今回そもそも非公式のブレードすら持たずに手ぶらで参加しました。

*5:現実には、非公式のブレードを持っている人もいる。ただ、投票時の画面注釈に「公式ブレード」という表記があったこともあり、運営が指定した色以外の色がどのように扱っているのかは不明。そもそも公式ブレードも量産されているブレードをベースに作られていると考えられるので、公式・非公式で色味に大きな差が生まれるのだろうか。。

*6:善子カラーがこのあたりの色相になったことに驚きですが、カップリングの運命を感じます。

*7:https://spice.eplus.jp/articles/218688

*8:https://www.pynote.info/entry/opencv-connected-components-labeling

*9:真っ先に思いつく改善は、HSVの残りのSとVを特徴量として加えることでしょうか。今回あまり時間を使えてないのでためしていませんが。。