あなたはトレカのカード枚数を数えたことがあるか - カード自動認識スキャナーを作った話
0. ご挨拶
トラストハブAIチームの佐藤秋彦です。トラストハブ社内では主に画像解析系の機械学習プロダクトを作っております。今回はカードの高速自動分類機をご紹介します!
1. mission: あなたはカードゲームのカードを数えたことがあるか
著者は小学生の頃から元祖トレーディングカードゲームマジック・ザ・ギャザリングが大好きで、私自身の人生はもう20年以上マジックと共にある言っても過言ではありません。大学生の頃にはカードショップでアルバイトしたことも大会のために遠征したこともあり、現在トラストハブで仕事しているのもカードゲーム関係の企業であるからこそです。
さて、これほどまでにカードを愛している私であっても、カードを整頓し、どのカードが何枚あるかを集計する業務は心が折れそうになります。なぜならトレーディングカードゲームのカードは1タイトルにつき最低でも1000種類を超えるし、カード自体にバーコードがついているわけではないので在庫登録システムに品目を登録するのには1枚1枚目視でカードを確認して、カード名を検索して枚数を入力しなくてはならないからです。手作業の場合、1枚の登録には最低でも10秒の時間がかかってしまう上、作業時間が長引けば能率は下がり打ち間違いも増えてしまいます。
トラストハブはClove Baseというカードショップや、Cloveストアという通販サイトを運営しており、これらの在庫は主にお客様からの買取によって確保しています。したがって、別々のお客様からお売りいただいたカードの束を販売するために在庫登録システムに1枚ずつ登録する作業が必要となります。
図1:作業イメージ。「さ、今からこれ全部打ち込んで。」
そこで、スキャナーを用いてカードを読み取り、トラストハブが内製している在庫登録システムのAPIを叩いて在庫登録をするシステムを作りました。
2. 概要:画像の雰囲気で識別
カードの画像から種類を特定するために、まず最初に試したアプローチは、カードの型番をOCRで読み取る方法です。しかしこの方法は、型番の位置や背景色がカードによってまちまちであるため誤読が多く、あまり精度が出ませんでした。
そこで、本システムではカードの画像(イラスト)情報を元にカードを識別することとしました。幸い、カードは種類が多いものの視認性に優れたデザインをしているため、解像度を落としても人間は識別することができます。また、カードの上半分のデザインさえ見ることができればカードの識別には十分な情報であるため、カードの上半分のみを読み取りに使用することにしました。それゆえ、CNNを用いてカードの特徴量を取り出す際もあまり高次元のベクトルにエンコードする必要はありません。
図2: 解像度を落として上半分を切り抜いても(右上)十分識別可能である。
3. 課題:30000キーの最近傍探索問題を考える
今回の開発で、カードの特徴量ベクトルを低次元にエンコードできることは追い風ですが、マジックやポケモンなどの歴史の長いタイトルは30000種類以上のカードがあり、超多クラス分類をしなくてはならないことは向かい風です。
そこで、本システムではオートエンコーダを用いて画像を500次元にエンコードし、そのベクトルの引き当てを使ってカードの特定を行うことにしました。すなわち推論前に全てのポケモンカードに対してベクトルを割り振り、それらをデータベース上のID(collectible id)と紐付けておき、推論時にスキャンしたカード画像をエンコードしたベクトルがどのcollectible idのカードと最も近いかを元にスキャンしたカードの特定を行います。この処理を「引き当て」と呼んでいます。
図3: 本システムの模式図。
4. 手法
4-1. model:エンコーダ・デコーダは非常にシンプル
カード画像をエンコードするためにはエンコーダ・デコーダがそれぞれCNN3層からなるオートエンコーダを用います。
図4: モデル構造の模式図。コードはこの記事末尾のAppendixを参照。
上図がオートエンコーダの模式図です。基本的に活性化関数はReLU関数を使っていますが、500次元のベクトルにエンコードする際だけは活性化関数をSigmoid関数にしています。ReLU関数にした場合は疎な(多くの成分が0である)ベクトルになりがちで、引き当てがうまくいかなかったためです。また、Poolingを使わず、Stride=2にすることでダウンサンプリングをしています。
また、学習する際、Input側の画像にはAugmentationとしてインパルスノイズを加えたり、わずかに回転させたりしています。Output側の画像にはAugmentationを行っていません。これは、スキャンしたカード画像の向きが僅かに傾いていたり、フォイル加工の光沢などによりばらついたりすることを想定しています。
図5: Inputで用いられる処理済みの画像の例。
4-2. ベクトル検索:FAISSがベクトル引き当てを行う
FAISSはMeta社が開発したベクトル検索アルゴリズムです。検索が高速で実装が簡単だったので今回採用しました。今回はIndexFlatL2(Brute-Force手法)を用いてスキャンしたカードの特徴ベクトルがどのカードの特徴ベクトルと最も近いか検索を行っています。
検索対象キーが約30000種類ある場合も、1回の引き当てにかかる時間は0.1秒程度です。
参考:https://faiss.ai/cpp_api/struct/structfaiss_1_1IndexFlatL2.html
4-3. 同イラスト・型番違いへの対処
カードゲームに詳しい方ならご存知かもしれませんが、トレーディングカードゲームにはしばしば「再録」があります。ゲームでよく使われるカードが別のカードセットにも再度収録されることです。これらのカードはイラストは同じでも、型番が異なっているため、店頭では別々に管理しなくてはなりません。
図6:「型番違い」の例。左の型番はs12a 111/172であるのに対し、右の型番はs11 081/100である。ゲーム上は全く同じカードとして扱うが、収録されているパックが異なるため、異なる型番を持っている。
本システムでは、推論時に新たにスキャンしたカードをエンコーダでベクトル化したベクトル(クエリベクトル)から最近傍のベクトルを持つカードのベクトルとのユークリッド距離を
ユーザはデスクトップアプリ上で、候補の中から、手元のカードと型番が一致しているものを選択してクリックすることで正しいカードをAPIに送信します。
図7:中心の黒点はクエリベクトルを意味する。赤◆はカードの特徴量ベクトルのうち、クエリベクトルから最近傍(距離
5. 人間 VS カードスキャナー
5-1. それぞれのインターフェース
カード100枚の品目登録を、人間の手入力の場合とカードスキャナーを使った場合とで比較しました。
手入力の場合、カード品目登録画面でカード名や型番を打ち込み、検索結果から手元のカードに一致するカードを選択します。
図8: ブラウザのカード登録用インターフェース。
一方、カードスキャナーを用いて登録する際は、カードスキャン専用アプリをデスクトップで立ち上げて、アプリ内でスキャナー操作・型番の選択・APIへの送信を行います。
図9: データの流れの概念図。
5-2. 勝負の結果:引当の成功率
熟練のカードゲーマーも機械も、カードの読み間違いはしませんでした。ともに引き当て成功率は100パーセントです。
5-3. 勝負の結果:処理時間
100枚のカードを登録したタイムは…
カードスキャナでの登録は180秒、人間による手入力は1500秒以上でした。(人間は300秒かけて20枚やったところで心が折れたので時間を5倍しています。)
カードスキャナ側の操作の方が圧倒的に少ないため、当然の結果かと思います。
本システムが店頭に導入されるのはこれからですが、カードショップに導入されて人間の作業時間を減らしてくれるのが楽しみです。
6. 最後に:著者が思い描く未来のカードショップ
著者は今回のプロダクトでカードショップ店員の仕事がなくなってリストラが進み、不幸な人が生まれてしまうとは考えていません。私はカードショップの魅力は、カードの売買のみならず大会や交流会などのコミュニティにこそあると考えています。本プロダクトによってカード整頓に割かれていた人的リソースがカードゲーマーのコミュニティ作りに回されるようになり、カードゲーム文化がより豊かになることを願っています。
Appendix: モデルの定義のコード
import torch
import torch.nn as nn
import torch.nn.functional as F
HEIGHT = 80
WIDTH = 96
class AE(nn.Module):
def __init__(self, z_dim): # レイヤー構成
super(AE, self).__init__()
self.conv2d_enc1 = nn.Conv2d(
3, 16, kernel_size=2, stride=2, padding=0, bias=True
)
self.conv2d_enc2 = nn.Conv2d(
16, 32, kernel_size=2, stride=2, padding=0, bias=True
)
self.conv2d_enc3 = nn.Conv2d(
32, 32, kernel_size=2, stride=2, padding=0, bias=True
)
self.flatten_enc1 = nn.Flatten()
self.dense_enc1 = nn.Linear(
int(HEIGHT / 8) * int(WIDTH / 8) * 32, z_dim, bias=True
)
self.batchnorm1 = nn.BatchNorm1d(z_dim)
self.batchnorm2 = nn.BatchNorm1d(z_dim)
self.dense_dec2 = nn.Linear(z_dim, int(HEIGHT / 8) * int(WIDTH / 8) * 32)
self.conv2dt_dec1 = nn.ConvTranspose2d(
32, 32, kernel_size=2, stride=2, bias=True
)
self.conv2dt_dec2 = nn.ConvTranspose2d(
32, 16, kernel_size=2, stride=2, bias=True
)
self.conv2dt_dec3 = nn.ConvTranspose2d(
16, 3, kernel_size=2, stride=2, bias=True
)
def _encoder(self, x): # inputのxからzを作る
x = F.relu(self.conv2d_enc1(x))
x = F.relu(self.conv2d_enc2(x))
x = F.relu(self.conv2d_enc3(x))
x = self.flatten_enc1(x)
x = self.dense_enc1(x)
z = self.batchnorm1(x)
z = F.sigmoid(z)
z = self.batchnorm2(z)
return z
def _decoder(self, z): # zからoutputのyを作る
y = F.relu(self.dense_dec2(z))
y = y.reshape(-1, 32, int(HEIGHT / 8), int(WIDTH / 8))
y = F.relu(self.conv2dt_dec1(y))
y = F.relu(self.conv2dt_dec2(y))
y = F.sigmoid(self.conv2dt_dec3(y))
return y
def forward(self, x):
z = self._encoder(x) # inputをzにencode
y = self._decoder(z) # zをoutputにdecode
return y, z
def loss(self, x, target):
z = self._encoder(x)
y = self._decoder(z)
return torch.mean((target - y) ** 2)
Discussion