10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ワーフリのスクショから編成を画像解析してみた① ―キャラ・装備枠の特定―

Posted at

#経緯
ワーフリのパーティを共有できるWEBサービス**ワーフリパーティファインダー**を運営している知り合いから、スクショだけでパーティを投稿出来るようにしたいという話を聞き画像解析モデルの作成を手伝ったという話。

#ワーフリ(ワールドフリッパー)とは
Cygamesが公開しているノンストップ体当たりアクションゲーム。
ゲーム画面やシナリオシーンはドットアニメーションを基調としたデザインで可愛い。

プレイヤーは手持ちから6人のキャラと6つの装備を編成して一つのパーティを作成し、各種クエストに挑戦する。 ↓パーティ編成画面の例 ![編成画面の例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679781/ddadf05c-fbdb-ba90-d89c-2744742cdcfc.png)

#ワーフリパーティファインダーとは(宣伝)
ユーザー投稿型パーティ検索サービス。誰でもパーティを投稿でき、投稿時に仮想敵や使用スタイル1の情報を入力してもらうことで多角的に検索出来るようになっている。
また外部サイト2で登録した手持ちキャラ・装備の情報に基づき、編成出来るパーティだけ検索するなんてのも可能。
↓検索結果の例
検索結果の例

#パーティとは
パーティは3つのユニゾンで構成され、1つのユニゾンはキャラを編成するメインサブ、装備を編成する武器・アビリティソウル(以下アビソ)の4つのポジションで構成される。
ポジションの説明
編成情報を共有する際は編成されているキャラ・装備をただリストアップするだけでなく、そのキャラ・装備がどのユニゾンのどのポジションに編成されているかまで伝えなければいけない。
というのも、各キャラ・装備が持つアビリティ3には指定されたポジションでのみ発動するものが存在したり4、ユニゾンの順番に応じて自動操作時の挙動が変わるケースが存在したりするからである。

#ゴール
編成画面のスクショから3ユニゾンx4ポジション=12ポジションそれぞれについてどのキャラ・装備が編成されているかを取得し、ワーフリパーティファインダーのコード体系に基づいて文字列に変換する。
変換イメージ

#全体の処理フロー
試行錯誤している内にマッチング手法の玉手箱と化した。

  1. 形状マッチングでキャラ・装備の表示枠を特定(この記事)
  2. 特徴点マッチングでキャラを特定(記事作成中)
  3. テンプレートマッチングで武器を特定(記事作成中)
  4. 色々前処理した上でテンプレートマッチングでアビソを特定(記事作成中)

#開発環境
Google Colaboratory
Python 3.7.10
opencv-python 4.1.2.30

#形状マッチングでキャラ・装備の表示枠を特定
最初はキャラ・装備のイラストが入力画像のどこに表示されているかを特定する。
ワーフリはデバイスによって画面のレイアウト・解像度が異なるため、単純な決め打ちでの指定は不可能だった。一方でキャライラストやドット絵が表示される辺りのレイアウトは固定であったため、特徴的なサブキャラの吹き出しの輪郭を検出し、そこからの決め打ちで対応することとした。
↓いくつかのデバイスのスクショを重ねた例、スキル表示などは位置がズレているがイラストの辺りは一致している。
いくつかのデバイスのスクショを重ねた例

##処理フロー

  1. 画像の取り込み
  2. リサイズ・二値化
  3. 輪郭の抽出
  4. サブキャラの表示枠を特定
  5. 2人目のサブキャラの表示枠の座標・幅高さを特定
  6. 2人目のサブキャラの座標から決め打ちで12ポジションの表示座標を算出

ゴールイメージ

##コード
当記事で紹介する範囲のコードは以下の通り。
試す場合はGoogle Colabにコピペし、後述のmask_sub.png, mask_equip.png, ss.jpgにリネームした適当な編成スクショをカレントフォルダに配置して実行すると前述のような3連画像が出力される。

GoogleColab
import cv2
import numpy as np
from google.colab.patches import cv2_imshow

# 画像の縦連結
# 参考:https://note.nkmk.me/python-opencv-hconcat-vconcat-np-tile/
def vconcat_resize_min(im_list, interpolation=cv2.INTER_CUBIC):
    w_min = min(im.shape[1] for im in im_list)
    im_list_resize = [cv2.resize(im, (w_min, int(im.shape[0] * w_min / im.shape[1])), interpolation=interpolation)
                      for im in im_list]
    return cv2.vconcat(im_list_resize)

# 画像の横連結
# 参考:https://note.nkmk.me/python-opencv-hconcat-vconcat-np-tile/
def hconcat_resize_min(im_list, interpolation=cv2.INTER_CUBIC):
    h_min = min(im.shape[0] for im in im_list)
    im_list_resize = [cv2.resize(im, (int(im.shape[1] * h_min / im.shape[0]), h_min), interpolation=interpolation)
                      for im in im_list]
    return cv2.hconcat(im_list_resize)

# 画像に複数の長方形をまとめて描画
def cv2_draw_rectangles(img_in, rects):
  img_out = img_in
  for rect in rects:
    x, y, w, h = rect
    img_out = cv2.rectangle(img_out, (x, y), (x + w, y + h), (0, 255, 0), 8)
  return img_out

# 事前に準備したサブキャラ枠と武器枠のマスク画像を用いて形状マッチング
# 入力された輪郭全てに対するサブキャラ枠か否か、武器枠か否かの真偽値リストを返す
def match_sub_equip_shapes(contours):
  # mask_sub.pngはインデックスカラーモードで作成してあるためdecolorでグレースケール化
  img = cv2.imread('mask_sub.png')
  img_gray = cv2.decolor(img)[0]
  cont = cv2.findContours(img_gray, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
  msk_sub = cont[0]

  # mask_equip.pngは通常のカラーモード(透過なし)で作成してあるためBGR2GRAYでグレースケール化
  # mask_subとモードが異なる理由は無い。何故変えた。
  img = cv2.imread('mask_equip.png')
  img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
  cont = cv2.findContours(img_gray, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
  msk_equip = cont[0]

  thres_sub = 0.008
  thres_equip = 0.002
  out = []
  for i, cont in enumerate(contours):
    out.append({
        'index': i,
        'is_sub': cv2.matchShapes(cont, msk_sub, cv2.CONTOURS_MATCH_I1, 0) < thres_sub,
        'is_equip': cv2.matchShapes(cont, msk_equip, cv2.CONTOURS_MATCH_I1, 0) < thres_equip
    })
  return out

# サブキャラ枠の外接矩形のリストから12ポジションの座標算出の基準となる、
# 2人目のサブキャラ枠のXY座標・幅高さを算出
# サブキャラ枠を2つだけ検出した時用に画像全体の解像度も入力
def rects2base(rects_sub, img_w, img_h):
  # X座標の算出はサブキャラ枠を何個検出したかで条件分岐
  if rects_sub is None:
    # 検出できなかったら無視
    return None
  elif len(rects_sub) < 2:
    # 2個未満しか検出できなかったら無視
    return None
  elif len(rects_sub) == 2:
    # 2個検出していたらその2つから2人目のX座標を算出する
    positions = []
    for rect in rects_sub:
      positions.append(int(rect[0] / (img_w / 3)))
    if 0 in positions and 1 in positions:
      # 1人目と2人目の枠を検出していると断定
      x = rects_sub[positions.index(1)][0]
    elif 0 in positions and 2 in positions:
      # 1人目と3人目の枠を検出していると断定
      x = int(sum([i[0] for i in rects_sub]) / len(rects_sub))
    elif 1 in positions and 2 in positions:
      # 2人目と3人目の枠を検出していると断定
      x = rects_sub[positions.index(1)][0]
    else:
      # 何人目の枠を検出したかはっきりしないので無視
      return None
  elif len(rects_sub) == 3:
    # 3個検出していたらその3つの平均
    x = int(sum([i[0] for i in rects_sub]) / len(rects_sub))
  else:
    # 4個以上検出していたら無視
    return None

  # Y座標・幅高さはめんどくさいので単純に平均
  y = int(sum([i[1] for i in rects_sub]) / len(rects_sub))
  w = int(sum([i[2] for i in rects_sub]) / len(rects_sub))
  h = int(sum([i[3] for i in rects_sub]) / len(rects_sub))
  return {'x':x, 'y':y, 'w':w, 'h':h}

# 2人目のサブキャラの表示枠からの12ポジション(+タイトル)の表示枠の相対位置マスタ
img_detect_prop_wh = (116, 167)
img_detect_prop = [
    {'unison': 1, 'position': 'main', 'detect': {'wh': (94, 117), 'xy': (-345, -144)}},
    {'unison': 1, 'position': 'sub', 'detect': {'wh': (96, 63), 'xy': (-239, 35)}},
    {'unison': 2, 'position': 'main', 'detect': {'wh': (94, 117), 'xy': (-96, -144)}},
    {'unison': 2, 'position': 'sub', 'detect': {'wh': (96, 63), 'xy': (12, 35)}},
    {'unison': 3, 'position': 'main', 'detect': {'wh': (94, 117), 'xy': (154, -144)}},
    {'unison': 3, 'position': 'sub', 'detect': {'wh': (96, 63), 'xy': (260, 35)}},
    {'unison': 1, 'position': 'weapon', 'detect': {'wh': (95, 95), 'xy': (-239, -115)}},
    {'unison': 1, 'position': 'soul', 'detect': {'wh': (85, 115), 'xy': (-282, 226)}},
    {'unison': 2, 'position': 'weapon', 'detect': {'wh': (95, 95), 'xy': (11, -115)}},
    {'unison': 2, 'position': 'soul', 'detect': {'wh': (85, 115), 'xy': (-43, 226)}},
    {'unison': 3, 'position': 'weapon', 'detect': {'wh': (95, 95), 'xy': (262, -115)}},
    {'unison': 3, 'position': 'soul', 'detect': {'wh': (85, 115), 'xy': (196, 226)}},
    {'unison': 0, 'position': 'title', 'detect': {'wh': (435, 36), 'xy': (-272, -269)}}
]

# メイン処理
def show_position_area(img_path):
  img = cv2.imread(img_path)
  out_w = 1200
  threshold = 240
  img = cv2.resize(img, (out_w, int(out_w / img.shape[1] * img.shape[0])))
  img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
  img_gray = cv2.GaussianBlur(img_gray, (5, 5), 0)
  img_gray = cv2.threshold(img_gray, threshold, 255, cv2.THRESH_BINARY)[1]
  contours = cv2.findContours(img_gray, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
  contours = [i for i in contours if cv2.contourArea(i) > (out_w ** 2) / 80]
  match_result = match_sub_equip_shapes(contours)
  rects_sub = [list(cv2.boundingRect(cont)) for i, cont in enumerate(contours) if match_result[i]['is_sub']]

  img_cont_out = cv2.cvtColor(img_gray.copy(), cv2.COLOR_GRAY2BGR)
  cv2.drawContours(img_cont_out, [cont for i, cont in enumerate(contours) if match_result[i]['is_sub']], -1, color=(0, 0, 255), thickness=8)

  base = rects2base(rects_sub, img.shape[1], img.shape[0])
  if base is not None:
    rects = []
    for p in img_detect_prop:
      rects.append({
          'unison': p['unison'], 
          'position': p['position'], 
          'x': max(base['x'] + int(p['detect']['xy'][0] / img_detect_prop_wh[0] * base['w']), 0), 
          'y': max(base['y'] + int(p['detect']['xy'][1] / img_detect_prop_wh[1] * base['h']), 0), 
          'w': int(p['detect']['wh'][0] / img_detect_prop_wh[0] * base['w']), 
          'h': int(p['detect']['wh'][1] / img_detect_prop_wh[1] * base['h'])})
    img_rect_out = img.copy()
    img_rect_out = cv2_draw_rectangles(img_rect_out, [(i['x'], i['y'], i['w'], i['h']) for i in rects])

    cv2_imshow(hconcat_resize_min([img, img_cont_out, img_rect_out]))

display_position_area('ss.jpg')

##マスク画像
↓mask_sub.png(真っ白だが画像が埋め込まれている)
mask_sub.png
↓mask_equip.png
mask_equip.png

##1.画像の取り込み
みんな大好きcv2.imread()

show_position_area
  img = cv2.imread(img_path)

imgの例
ダウンロード.png

##2.リサイズ・二値化
サブキャラの吹き出しっぽい輪郭を検出するために入力画像を前処理。
妙な解像度の画像が入力された場合に備えて幅1200pxに縦横比固定で拡大。1200pxにしたのは適当。
グレースケール化・ノイズ対策のガウシアンぼかしを実施してしきい値240で二値化。
しきい値240はいくつか試してサブキャラの吹き出しが潰れずかつ極力大きい値をとった。
cv2.threshold()の1つ目の出力retvalは使わないので[1]を指定して二値化画像のみ取得。

show_position_area
  out_w = 1200
  threshold = 240
  img = cv2.resize(img, (out_w, int(out_w / img.shape[1] * img.shape[0])))
  img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
  img_gray = cv2.GaussianBlur(img_gray, (5, 5), 0)
  img_gray = cv2.threshold(img_gray, threshold, 255, cv2.THRESH_BINARY)[1]

img_grayの例
img_gray

##3.輪郭の抽出
cv2.findContours()で閉じた図形の輪郭を抽出。輪郭のリストが返されるためその中からサブキャラの吹き出しと思しき輪郭を後段で特定するのだが、このままだと明らかに求める輪郭ではない小さなものも含まれている。
そのため入力画像の解像度比で一定以上の面積を持つ輪郭のみに限定し小さなものを予め除外している。

show_position_area
  contours = cv2.findContours(img_gray, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
  contours = [i for i in contours if cv2.contourArea(i) > (out_w ** 2) / 80]

↓面積による除外前後の例、赤線が検出した輪郭
68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3637393738312f34666139613838352d633163322d366339382d356338642d3333356131633664323637372e706e67_結合.jpg

##4.サブキャラの表示枠を特定
前段の輪郭リストと、事前に用意したサブキャラの吹き出しの形をしたマスク画像をcv2.matchShapes()を使い比較、しきい値以上に似ているかどうかを判定している。
なおmatch_sub_equip_shapes()では武器の角丸正方形と似ているかについても判定しているが、後続の処理でその結果は使っていない。

show_position_area
  match_result = match_sub_equip_shapes(contours)
match_sub_equip_shapes
# 事前に準備したサブキャラ枠と武器枠のマスク画像を用いて形状マッチング
# 入力された輪郭全てに対するサブキャラ枠か否か、武器枠か否かの真偽値リストを返す
def match_sub_equip_shapes(contours):
  # mask_sub.pngはインデックスカラーモードで作成してあるためdecolorでグレースケール化
  img = cv2.imread('mask_sub.png')
  img_gray = cv2.decolor(img)[0]
  cont = cv2.findContours(img_gray, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
  msk_sub = cont[0]

  # mask_equip.pngは通常のカラーモード(透過なし)で作成してあるためBGR2GRAYでグレースケール化
  # mask_subとモードが異なる理由は無い。何故変えた。
  img = cv2.imread('mask_equip.png')
  img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
  cont = cv2.findContours(img_gray, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
  msk_equip = cont[0]

  thres_sub = 0.008
  thres_equip = 0.002
  out = []
  for i, cont in enumerate(contours):
    out.append({
        'index': i,
        'is_sub': cv2.matchShapes(cont, msk_sub, cv2.CONTOURS_MATCH_I1, 0) < thres_sub,
        'is_equip': cv2.matchShapes(cont, msk_equip, cv2.CONTOURS_MATCH_I1, 0) < thres_equip
    })
  return out

['is_sub']==Trueな輪郭のみ表示した例
s.jpg

##5.2人目のサブキャラの表示枠の座標・幅高さを特定
前段で抽出したサブキャラの吹き出しの輪郭情報をcv2.boundingRect()で単純な外接矩形に変換し、後段の決め打ちの基準となる2人目の吹き出しの座標・幅高さを取得している。
座標・幅高さは基本は3つの吹き出しの平均を取ることで2人目の吹き出しの情報を取得しているが、仮に2つしか検出出来なかった場合X座標は平均だと見当違いな値になりかねないため細かい処理を追加している。
具体的には入力画像の幅を三分割した上で各吹き出しのX座標がどこに含まれるかを算出することでどの2つの吹き出しを取得したのかを断定し、それに応じてX座標の計算方法を切り替えている。
前段で吹き出しが1個以下または4個以上検出出来た場合はエラーとして無視している。

show_position_area
  rects_sub = [list(cv2.boundingRect(cont)) for i, cont in enumerate(contours) if match_result[i]['is_sub']]
  base = rects2base(rects_sub, img.shape[1], img.shape[0])
rects2base
# サブキャラ枠の外接矩形のリストから12ポジションの座標算出の基準となる、
# 2人目のサブキャラ枠のXY座標・幅高さを算出
# サブキャラ枠を2つだけ検出した時用に画像全体の解像度も入力
def rects2base(rects_sub, img_w, img_h):
  # X座標の算出はサブキャラ枠を何個検出したかで条件分岐
  if rects_sub is None:
    # 検出できなかったら無視
    return None
  elif len(rects_sub) < 2:
    # 2個未満しか検出できなかったら無視
    return None
  elif len(rects_sub) == 2:
    # 2個検出していたらその2つから2人目のX座標を算出する
    positions = []
    for rect in rects_sub:
      positions.append(int(rect[0] / (img_w / 3)))
    if 0 in positions and 1 in positions:
      # 1人目と2人目の枠を検出していると断定
      x = rects_sub[positions.index(1)][0]
    elif 0 in positions and 2 in positions:
      # 1人目と3人目の枠を検出していると断定
      x = int(sum([i[0] for i in rects_sub]) / len(rects_sub))
    elif 1 in positions and 2 in positions:
      # 2人目と3人目の枠を検出していると断定
      x = rects_sub[positions.index(1)][0]
    else:
      # 何人目の枠を検出したかはっきりしないので無視
      return None
  elif len(rects_sub) == 3:
    # 3個検出していたらその3つの平均
    x = int(sum([i[0] for i in rects_sub]) / len(rects_sub))
  else:
    # 4個以上検出していたら無視
    return None

  # Y座標・幅高さはめんどくさいので単純に平均
  y = int(sum([i[1] for i in rects_sub]) / len(rects_sub))
  w = int(sum([i[2] for i in rects_sub]) / len(rects_sub))
  h = int(sum([i[3] for i in rects_sub]) / len(rects_sub))
  return {'x':x, 'y':y, 'w':w, 'h':h}

##6.2人目のサブキャラの座標から決め打ちで12ポジションの表示座標を算出
前段で2人目のサブキャラの吹き出しの座標・幅高さが取得できたので、そこから事前に準備した相対座標・幅高さマスタimg_detect_prop, img_detect_prop_whに基づいて12ポジションの表示座標を算出しrectsに格納している。
後段の各キャラ・装備を特定する工程では、このrectsに格納された座標・幅高さで入力画像を切り出し、検出モデルにかける、という流れを取っている。

show_position_area
  if base is not None:
    rects = []
    for p in img_detect_prop:
      rects.append({
          'unison': p['unison'], 
          'position': p['position'], 
          'x': max(base['x'] + int(p['detect']['xy'][0] / img_detect_prop_wh[0] * base['w']), 0), 
          'y': max(base['y'] + int(p['detect']['xy'][1] / img_detect_prop_wh[1] * base['h']), 0), 
          'w': int(p['detect']['wh'][0] / img_detect_prop_wh[0] * base['w']), 
          'h': int(p['detect']['wh'][1] / img_detect_prop_wh[1] * base['h'])})
img_detect_prop
img_detect_prop_wh = (116, 167)
img_detect_prop = [
    {'unison': 1, 'position': 'main', 'detect': {'wh': (94, 117), 'xy': (-345, -144)}},
    {'unison': 1, 'position': 'sub', 'detect': {'wh': (96, 63), 'xy': (-239, 35)}},
    {'unison': 2, 'position': 'main', 'detect': {'wh': (94, 117), 'xy': (-96, -144)}},
    {'unison': 2, 'position': 'sub', 'detect': {'wh': (96, 63), 'xy': (12, 35)}},
    {'unison': 3, 'position': 'main', 'detect': {'wh': (94, 117), 'xy': (154, -144)}},
    {'unison': 3, 'position': 'sub', 'detect': {'wh': (96, 63), 'xy': (260, 35)}},
    {'unison': 1, 'position': 'weapon', 'detect': {'wh': (95, 95), 'xy': (-239, -115)}},
    {'unison': 1, 'position': 'soul', 'detect': {'wh': (85, 115), 'xy': (-282, 226)}},
    {'unison': 2, 'position': 'weapon', 'detect': {'wh': (95, 95), 'xy': (11, -115)}},
    {'unison': 2, 'position': 'soul', 'detect': {'wh': (85, 115), 'xy': (-43, 226)}},
    {'unison': 3, 'position': 'weapon', 'detect': {'wh': (95, 95), 'xy': (262, -115)}},
    {'unison': 3, 'position': 'soul', 'detect': {'wh': (85, 115), 'xy': (196, 226)}},
    {'unison': 0, 'position': 'title', 'detect': {'wh': (435, 36), 'xy': (-272, -269)}}
]

rectsに格納された座標を入力画像に描画した例
入力画像に描画した例

#参考
https://note.nkmk.me/python-opencv-hconcat-vconcat-np-tile/
https://water2litter.net/rum/post/python_cv2_outerrectangle/
https://axa.biopapyrus.jp/ia/opencv/detect-contours.html
https://pystyle.info/opencv-match-shape/

  1. 手動で操作する前提なのかオートで操作される前提なのか等

  2. ワーフリ所有率チェッカー
    サイトはこちら
    従来パーティを共有するには前述のスクショをSNSに投稿する形でしか行えず、後から体系的な検索が出来なかったところに踏み込んだ形。
    なお運営に筆者は関わってないので問い合わせ等は@WFPartyFinder へお願いしたい。

  3. 自身の攻撃力を50%上昇させるなどのバフ・デバフのこと

  4. 例としてほぼ全てのキャラは一番目のユニゾンのメイン(=リーダー)に編成されたときのみ発動するアビリティを所持している

10
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?