TechConアプリ 2020 開発 / スタンプ編

by Aoi Nakanishi | March 27, 2020
event | #dena-techcon #tensorflow #flutter #android #ios

オートモーティブ事業本部モビリティー・インテリジェント開発部でエンジニアをしている中西 葵です。今年のTechCon 2020ではスタンプラリーを行う予定でした。この記事ではFlutter x TensorFlow Liteでスタンプ機能を実現しましたので物理的な電子スタンプの作り方とそれを識別するソフトウェア部分の作り方について解説します。

TechCon 2020は惜しくもオンラインでの開催となってしまいましたが自宅でスタンプを作ってお楽しみください。

関連記事

こちらの記事もあわせてご覧ください。

ブレスト

スタンプラリーやりたい!

との一言からスタンプラリーを実現する旅が始まりました。

アプリでスタンプをどうやって実現するのか?

  • QRコード
  • NFC
  • BLEビーコン
  • 音波ビーコン
  • 屋内位置測定(WiFi, 地磁気)
  • カメラ画像
  • 電子スタンプ

などなどアイデアとしてはいくつか挙がりましたが、私がやったこと無いのでやってみたいという理由から電子スタンプでやることにしました!今年のDeNA TechConはクラフトマンシップがテーマになっていたのでやってみたいという気持ちを中心にものづくりが進んでいきました。

今回実現したかった 電子スタンプ はスマホの画面に物理的なスタンプを押して画面上にスタンプを表示するというものになります。

仕組みの検討

まず現時点で普及しているスマホでそのまま実行できる必要があるため、静電容量方式のタッチパネルが持つ機能を利用する必要があります。

Wikipedia: 静電容量方式

タッチパネルのマルチタッチの機能を利用することで、タッチされた箇所のパターンを認識して予め定義したパターンに一致するポイントにタッチされた場合に該当スタンプが押された状態にすることとしました。

素材

電気を通す導電性の素材であればタッチパネルが認識してくれるという事がわかったので素材の検討です。タッチパネルが認識するようなものとしてスタイラスペンタッチパネル対応手袋などがあります。他にも身近にある導電性の素材を探してみると色々ありました。

  • アルミホイル
  • 鉄などの金属
  • 乾電池
  • CPU等が刺さっているスポンジ
  • ガソリンスタンドの静電気を逃す樹脂
  • 静電気除去シート

検証

正しくマルチタッチが出来ていることの確認にAndroid端末のデバッグ機能を利用しました。

ポインタの表示

ポインタの表示

PoC(Proof of concept)

まず最初のPoCがこちらです。手元にあった某IoTデバイス用チップが刺さっていた導電性のスポンジが厚紙に貼り付けられたものをベースにして、マルチタッチとして認識させるために見ての通り輪ゴムで分離しました。PoCではありましたがスタンプとして持ちやすいように端を曲げるというこだわりの加工も施してあります。

実際にこの導電性スポンジを検証してみると、導電性スポンジを画面に押し付けただけでは認識しにくいということが分かってきました。流れる電気の量が少ないのが原因のようです。そこで乾電池でもタッチパネルが反応することを利用して、導電性スポンジに乾電池を付けることで強制的に電気を流すことにしました。これにより、直接スポンジに触れなくても電気を流すことが出来るようになります。

プロトタイプA

PoCで問題ないことが分かったので早速素材を購入して検証に入ります。いくつかの硬さや厚さの導電性スポンジ導電性ゴムを用意しました。スタンプラリーとしてイベント会場内で多数のスタンプを配置する予定だったため、加工のしやすさなども検証項目の一つです。

プロトタイプ素材

プロトタイプ素材

今回手に入った導電性スポンジは全て柔らかすぎて加工するのが難しいということが分かりました(下写真左)。また加工したものを検証しているとこれも柔らかいのが原因で力のかかり具合が安定せず、繰り返し使っていくうちにタッチ感度が悪くなってくるということも分かってきました。イベント会場で一日中使うには耐久性的にも難しそうです。

導電性ゴムですが、小学生の頃を思い出しながらゴム版はんこを作る感覚で軽く考えて作業に挑みましたが、そもそも目的が異なるので同じゴムと言っても硬度の違いからこちらもとても加工に時間がかかりました。ゴム版はんこのように削ってスタンプにするのは現実的に難しいということが分かり、突起部分のみをゴム版から切り出すことにしました。

作業風景

作業風景

プロトタイプB

その後、プロトタイプAよりも加工がしやすいものと言うことで、導電性シートが貼り付けられた静電気除去テープを発見し、木製のブロックに貼り付けて導電性接着剤導電性ゴムを貼り付けたものがこちらになります。重い作業が必要なのは導電性ゴムの切り抜きだけとなりました。

プロトタイプB

プロトタイプB

その後、ポンチで簡単にくり抜けないかな?というアイデアが出たので早速ポンチを購入して加工してみると良い感じにくり抜くことが出来ました。

ポンチで加工したもの

ポンチで加工したもの

ベータ版完成

プロト版の制作をしていく中で、指と指の間隔(約10mm)より短い距離でタッチされた場合はその中間点がタッチポイントとして認識されてしまい複数のタッチポイントとしては認識できないという事が分かってきました。タッチポイントを複数にする必要があり、プロトタイプBよりも大きめの素材を購入しポンチでくり抜いたゴムシートを貼り付けたものが以下になります。

β版スタンプ

β版スタンプ

作る際のポイントは

  1. 点と点の間は一定以上の距離を置くこと
  2. 手で持つ部分にも伝導性素材を貼ること

の2点です。手で持つ部分にも伝導性素材を貼ることにより、人の手を伝って電気が通りやすくなり、PoCで実験したようなボタン電池を用意する必要もなくなります。

大量生産開始

ここまで作業自体は一人でやってきましたが、いよいよ作業を分担して大量生産に取り組む段階です。制作を一緒に行っていただいたのは、システム本部CTO室から玉田さん、太田さん、斎藤さんの3名です。会議室に集合して作業を行いました。

ポンチをハンマーで叩いてゴムシートをくり抜く必要があったため、ヒカリエの会議室でも執務室から離れてなるべく他の皆さんの迷惑にならないような場所を探して作業を行いました。

スタンプワークショップ風景

スタンプワークショップ風景

※その後、紙に穴をあける穴あけパンチで簡単にくり抜く事が出来ることが分かり、ハンマーは不要になりました!

製品版完成

イベント会場でTechCon参加者の皆さんにもお披露目する予定でしたので見た目も綺麗に仕上げて頂きました。ありがとうございました!今年は使われることはありませんでしたが、いつかどこかで活用されることを祈っています

スタンプの完成品

スタンプの完成品

アプリの実装

ここからいよいよソフトウェアのパートになります。

Flutterでマルチタッチ

FlutterではListenerクラスを使ってタッチポインターを取得することが出来ます https://api.flutter.dev/flutter/widgets/Listener-class.html

final HashMap<int, Offset> _touchPoints = HashMap<int, Offset>();
void _onPointerDown(PointerEvent details) {
  _touchPoints[details.pointer] = details.position;
  _checkPointers();
}
void _onPointerMove(PointerEvent details) {
  _touchPoints[details.pointer] = details.position;
  _checkPointers();
}
void _onPointerUp(PointerEvent details) {
    _touchPoints.remove(details.pointer);
}

上記のようにタッチポイントを保持するようにし、タッチされた時とタッチポイントが移動したときに更新するようにします。

Listener(
      key: key,
      child: ウィジェット,
      onPointerDown: _onPointerDown,
      onPointerMove: _onPointerMove,
      onPointerUp: _onPointerUp,
      onPointerCancel: _onPointerUp,
    );

Listenerはこのようにタッチさせたいウィジェットに対してListenerを設定します。

画面サイズの扱い

タッチポイントは取得することが出来ましたが、複数の画面サイズや解像度に対応するには工夫をする必要があります。タッチされた部分の割合でタッチポイントを正規化することにしました。

今回はタッチされた点のうち最も左にある点を0もっとも右にある点を1とし、上下も同様に最上部、最下部の点を基準点とします。こうすることで、画面サイズに依存せず同じスタンプを利用している限り同じ数字として扱うことが出来るようになります。

Wikipedia: 正規化

正規化済みの基準点は以下のような値を用意しました。

P = [
  [0.0, 0.0], [0.0, 0.5], [0.0, 1.0],
  [0.5, 0.0], [0.5, 0.5], [0.5, 1.0],
  [1.0, 0.0], [1.0, 0.5], [1.0, 1.0],
]

9つの点をプロットしたのがこちらになります。

タッチポイントのばらつき

正確に等間隔でタッチポイントを取得できればこのままで問題ないのですが、

  • スタンプ制作上、厳密に等間隔に作るのは困難
  • 正確にゴムシート中心点がタッチポイントとして認識されるわけではない
  • 画面上に正確な角度でスタンプを押すのは困難

などの課題があります。これを回避するために、上記の基準点からずらした値でも認識できるようにします。

ランダムノイズをのせたサンプル

0.0 -> 0.01850672294782016
0.5 -> 0.4578696152746385
1.0 -> 0.9722905338971765

スタンプのパターン

先の9点のうちいずれかを取り除いたものを一つのパターン(スタンプ)として判定することにします。各点の中心からランダムにノイズを加えたもの+スタンプのパターンに応じて点を取り除いたものを一つのデータとします。

点を取り除く上での注意点として、以下のように気をつけました。

  • 基準となる左上と右下の点固定
  • 回転しても別のパターンと重ならない事

パターン1

パターン2

パターン3

データセット作成

TensorFlowで分類器を作ってスタンプを識別します。TechCon 2020ではBooth, Ask the speaker, Workshopの3種類のスタンプを用意することにしました。

  • 3種類のスタンプパターン
  • 指でチートできないようにする

の条件に合致するデータセットが学習のために必要になります。

以下が3種類のデータセットをプロットしたもで先に用意した基準点0.0,0.5,1.0を中心にランダムにずらした値をタッチポイントとして用意します。一つのデータには8つの点が入ります。

パターン1のデータセット

パターン2のデータセット

パターン3のデータセット

3種類のパターンを用意しただけでは必ず3つのうちのどれかに分類されることになりますので、それ以外のエラーとなるデータも用意する必要があります。エラー用データセットは、ランダムな8点をデータセットとして用意します。エラーデータとしては以下のようなパターンがあります

  • 全ての点がスタンプのパターンと異なる8点
  • 一部がスタンプのパターンと一致する8点
  • いずれかのスタンプと一致する8点(ランダムなので一部はこのパターンも含む)

エラーパターンのデータセット

また、スタンプを押し始める角度により、左上から順に入力される保証は無いのでデータとして用意した8点はランダムで並べ替えることにしました。アプリ側で取得したポイントを並び替えるという方法もありますが、その実装の手間も惜しんで全てTensorFlowにおまかせで分類できるようにしてあります。深く検証は行っていませんが、データをランダムに並べ替えたことで認識の精度は上がりました。

モデルの作成

入力は8 点 × 2 (xとy)の16点で アウトプットはSoftmaxした4つの値が返り、確率が高いもののフラグが立つようにします。インプットもアウトプットもシンプルなのでモデル構造もシンプルに全結合でレイヤーを重ねることにしました。

model = tf.keras.Sequential([
  tf.keras.layers.Flatten(input_shape=stamp_points_data.shape[1:]),
  tf.keras.layers.Dense(512, activation='relu'),
  tf.keras.layers.Dropout(0.25),
  tf.keras.layers.Dense(256, activation='relu'),
  tf.keras.layers.Dropout(0.25),
  tf.keras.layers.Dense(128, activation='relu'),
  tf.keras.layers.Dropout(0.25),
  tf.keras.layers.Dense(64, activation='relu'),
  tf.keras.layers.Dropout(0.25),
  tf.keras.layers.Dense(4, activation='softmax'),
])
model.compile(optimizer='adam',
              loss='sparse_categorical_crossentropy',
              metrics=['accuracy'])

のようDenseとDropoutだけのシンプルな構造になっています。

_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
flatten (Flatten)            (None, 16)                0         
_________________________________________________________________
dense (Dense)                (None, 512)               8704      
_________________________________________________________________
dropout (Dropout)            (None, 512)               0         
_________________________________________________________________
dense_1 (Dense)              (None, 256)               131328    
_________________________________________________________________
dropout_1 (Dropout)          (None, 256)               0         
_________________________________________________________________
dense_2 (Dense)              (None, 128)               32896     
_________________________________________________________________
dropout_2 (Dropout)          (None, 128)               0         
_________________________________________________________________
dense_3 (Dense)              (None, 64)                8256      
_________________________________________________________________
dropout_3 (Dropout)          (None, 64)                0         
_________________________________________________________________
dense_4 (Dense)              (None, 4)                 260       
=================================================================
Total params: 181,444
Trainable params: 181,444
Non-trainable params: 0
_________________________________________________________________

10万件のデータを使って97.80%という高い精度で認識できるようになりました。データセットを増やしても学習時間を増やしてもこれ以上精度は上がりませんでしたが、実用レベルの精度でスタンプパターンを識別できるようになったところでモデルの作成は終了です。

1000/1000 - 0s - loss: 0.0780 - accuracy: 0.9780
Untrained model, accuracy: 97.80%

TensorFlow Lite用にコンバート

converter = tf.lite.TFLiteConverter.from_keras_model_file(f"stamp.h5")
converter.optimizations = [tf.lite.Optimize.DEFAULT]
tflite_model = converter.convert()
with open(f"stamp.tflite", 'wb') as w:
    w.write(tflite_model)

このようにTensorFlow Lite用にモデル最適化を行いましたが、実機で実行しようとしたところiOSで読み込むことが出来ず最終的に以下の行はコメントアウトしました(最新版のコンバーターでどのような動きになるかは確認していません)

converter.optimizations = [tf.lite.Optimize.DEFAULT]

FlutterでTFLiteモデルの読み込み

その名もずばりtfliteというライブラリを使用しました。

https://pub.dev/packages/tflite

読み込み

Future _loadModel() async {
  Tflite.close();
  await Tflite.loadModel(
      model: "assets/stamp/stamp_model.tflite",
      labels: "assets/stamp/stamp_model.txt",
      numThreads: 1);
}

実行

List<dynamic> recognitions = await Tflite.runModelOnBinary(
  binary: _normalizedPointsToByteListFloat32(normalizedPoints),
  numResults: 1,
);

タッチポイントの型変換

Uint8List _normalizedPointsToByteListFloat32(
    List<List<double>> normalizedPoints) {
  var convertedBytes = Float32List(8 * 2);
  var buffer = Float32List.view(convertedBytes.buffer);
  int pointIndex = 0;
  normalizedPoints.forEach((List<double> point) {
    buffer[pointIndex++] = point[0];
    buffer[pointIndex++] = point[1];
  });
  return convertedBytes.buffer.asUint8List();
}

タッチポイントの正規化

List<List<double>> _normalize(List<List<double>> points) {
  List<double> arrX = [];
  List<double> arrY = [];
  List<List<double>> normalizedPoints = [];
  points.forEach((List<double> point) {
    arrX.add(point[0]);
    arrY.add(point[1]);
  });
  final lowerX = arrX.reduce(min);
  final upperX = arrX.reduce(max);
  final lowerY = arrY.reduce(min);
  final upperY = arrY.reduce(max);
  points.forEach((List<double> point) {
    final x = point[0];
    final y = point[1];
    normalizedPoints.add(
        [(x - lowerX) / (upperX - lowerX), (y - lowerY) / (upperY - lowerY)]);
  });
  return normalizedPoints;
}

ハマリポイント

実装も終わってから気がついたのですがiPhoneだと認識出来ませんでした。確認したところ、iPhoneではマルチタッチで5点までしか受け取ることが出来ませんでした。片手で操作することを考えたら5点あれば十分ですよね…

チート対策

この記事をここまで読んでいておかしいなと思う点がいくつかあるかもしれません

  • iPhoneでは5点までしか認識しない
  • スタンプには9点ある
  • データセットは8点って書いてある

など構造的に矛盾があることにお気づきでしょうか?TechConに参加して頂く皆さんはエンジニアの方が多いので、イベント開催時にはアプリを弄り倒したりすることも想定しており、いくつかのチート防止策が入っています。

認識モデルは上で説明したとおり8点を渡すようになっていますが、5点分のポイントでも認識出来るようにアプリ側で細工をしています。また完成したスタンプは9点ありますが、全てが導電性のゴムではなく通常のゴムシートも貼り付けてあり、見た目では全て同じスタンプに見えるようにしてあります。ブースを回った際にスタンプの裏側を見ても違いがわからないようにするためです。

また指を動かし続けるとスタンプが押されたと認識されてしまう可能性があるため、対策として一定数のトライが繰り返されるとスタンプとして認識されなくなるようにしてあります。ただし、実際のスタンプでも一定回数以上のエラーとなる可能性もあるため、復帰する方法も用意しておきました。

まとめ

開発期間が短かったこともあり、スタンプのパターンもシンプルに9つを並べただけでしたが、スタンプとデータセットを用意するだけでパターンはいくらでも増やすことが出来ます。応用すれば様々な大きさのイベントやお店でスタンプラリーを実現出来ると思います。FlutterとTensorFlow Liteを使って電子スタンプを実現したい方のお役に立てたら幸いです!

TechCon 2020がオンライン開催に変更になったため、参加者の皆様にアプリをお披露目する機会がありませんでしたが、ブログにて今回の取り組みを公開させていただくこととなりましたので他のTechCon記事と合わせてお楽しみください。