#TensorFlow.jsをつかってGithubのログイン画面を画像認証に置き換えてみた。
この記事はTensorFlow.jsを使って、Webで画像による本人認証機能を実装した話。をChrome拡張で実装し直した記事になります。
##作ったもの
- website: https://face-pass-keijun.herokuapp.com/
- Chrome拡張: https://chrome.google.com/webstore/detail/facepass-for-git/genlnamkabfpfjkcpdfnaeeigcopeldc?hl=ja
悪意のあるコードを差し込んだつもりはありませんが、内容がログイン画面に関係ます。場合によってはパスワードが盗めてしまうため、privateモードで遊んでみるのをお勧めします。
コードはGithubにあります
###遊び方
- 拡張機能をインストール後Githubログイン画面にアクセス
- 1秒後にフォームが表示されるので、"kuro"と入力
- 学習が始まり"Ready"と表示されたら、カメラの利用許可が求められるので許可
- 認証が始まり、カメラを指で隠した場合(画像が真っ暗)には"Succeeded"と表示され、元のフォームが表示される。それ以外は"Failed"と表示
##使用技術
- mlab
- TensorFlow.js
##TensorFlow.jsとは?
2018年3月にGoogleからTensorFlow.jsが公開されたライブラリであり、JavascriptでTensorFlowを用いた機械学習が実装できるようになっています。Javascriptで機械学習を書けることになんのメリットがあるんだと疑問に思う方も多数いると思いますが、多くの環境で動くJavascriptで機械学習が書けることはサーバーを介せず、フロント側のみで処理が完結するということです。メリットとしては以下のようなものでしょうか。
- サーバー側に負荷をかけずにモデルを使用することができる。
- 個々のユーザーにあったモデルを用意することができる。
- 非常に小さなモデルであれば、Pythonより早いらしい...
感覚としてもPythonで書くTensorFlowとかなり近い感覚で記述することができます。
React Nativeを用いればモバイル側でも、機械学習をフロントで完結することが可能となります(SwiftでもTensorFlow書けるらしいですね)。
また、Pythonで作成したモデルをJavascriptでロードし、使用するということも可能なので非常に柔軟な使い方ができます(もちろん逆も)。
モデルの作成
model = tf.sequential({
layers: [
tf.layers.flatten({inputShape: [7, 7, 256]}),
tf.layers.dense({
units: 100,
activation: 'relu',
kernelInitializer: 'varianceScaling',
useBias: true
}),
tf.layers.dense({
units: 10,
kernelInitializer: 'varianceScaling',
useBias: false,
activation: 'softmax'
})
]
})
コンパイル
const optimizer = tf.train.adam(0.0001);
model.compile({optimizer: optimizer, loss: 'categoricalCrossentropy'});
学習
model.fit(controllerDataset.xs, controllerDataset.ys, {
batchSize,
epochs: 20,
callbacks: {
onBatchEnd: async () => await tf.nextFrame()
}
})
機械学習を使っている人ならば見覚えのあるコードではないでしょうか?
#Chrome Extension
##manifest.json
拡張機能を作る場合にはmanifest.jsonが必須となります。特にこだわりもなく、難しいものを作ろうとしている訳でもないので、適当にネットからコピペして使いました 笑
manifest.json
{
"manifest_version": 2,
"version": "1.1",
"name": "FacePass for Git",
"icons": {
"16": "images/logo.png",
"48": "images/logo.png",
"128": "images/logo.png"
},
"description": "Githubのログイン画面で画像認証を行います。",
"browser_action": {
"default_icon": {
"16": "images/logo.png",
"24": "images/logo.png",
"32": "images/logo.png"
},
"default_title": "facepass extension"
},
"content_scripts": [{
"matches": ["https://github.com/login"],
"js": ["./js/tf.min.js", "./js/main.js"]
}]
}
matches: https://github.com/login で指定してgithubのログイン画面でのみmain.jsが読み込まれるように指定しています。
tf.min.jsはTensorFlow.jsのライブラリですね。公式サイトから入手できます。
画像はサーバーサイドのデザインに似たものを採用しました。
##main.jsの中身
Githubログイン画面のログインボタンをユーザーから隠して独自のフォームに置き換えます。
form_html = document.getElementsByClassName('auth-form-body')[0]
original_form_html = form_html.innerHTML
const text = '<p>このサイトはFacePassによりロックされています</p>'
const label = '<label for="login_field">Label</label>'
const input = '<input type="text" name="login" id="username-for-facepass" class="form-control input-block" tabindex="1" autocapitalize="off" autocorrect="off" autofocus="autofocus">'
const button = '<input id="images-for-facepass" name="commit" value="Fetch Images from FacePass" tabindex="3" class="btn btn-secondary btn-block" data-disable-with="Signing in…"></input>'
document.getElementById("images-for-facepass").addEventListener("click", fetch_images);
Githubのログイン画面からログインフォームをclassで取得しこれを独自のフォームで置き換えます。
元のHTMLはoriginal_form_htmlとして変数に格納しておきます。あとで認証が通ったらこのHTMLを戻して認証成功とします。
置き換えたフォームに直接Javascriptを記述すると、セキュリティの観点から弾かれるので、idを指定し、main.jsの中でイベントリスナーを追加することで関数を発火させます。
最後の行の一文でボタンにイベントリスナーを追加し、Fetch from FacePassがクリックされた際にfetch_images関数が発火するようにします。
##画像を取得
const username = document.getElementById("username-for-facepass").value
const preResponse = await fetch(`https://face-pass-keijun.herokuapp.com/<hide_for_security>?email=${username}`)
const response = await preResponse.json()
const {images, fake_images} = response
const image_string = images.map((item) => item.x_data)
const fake_image_string = fake_images.map((item) => item.x_data)
image_string.forEach((value) => {
const image_tensor = tf.tensor1d(value.split(','))
controllerDataset.addExample(image_tensor.reshape([1, 7, 7, 256]), 1)
})
fake_image_string.forEach((value) => {
const image_tensor = tf.tensor1d(value.split(','))
controllerDataset.addExample(image_tensor.reshape([1, 7, 7, 256]),
Math.floor(Math.random() * 9 ) + 2)
})
training()
ユーザーから入力されたラベルを用いて、サーバーのAPIを叩いてDBから画像を取得します(実際には画像データではなく、一つの長いStringデータ)。
レスポンスには以下のデータが含まれているので、それぞれを学習のためのデータセットとしてaddExample関数で保存します。
- ラベルに紐付いた画像: images
- ラベルに紐付かない画像: fake_images
データセットとして保存する際にimagesには1のラベルを、fake_imagesには2~9のラベルをランダムにつけます。これにより、学習の際に正解の画像を判定することが可能になります。
##画像データを用いて学習
準備が終わったデータセットを用いて実際に学習を進めていきます。コードは先ほど記述したものと同じです。念の為、簡単なエラーハンドリングを追加します。Googleが作成したモデルを転移学習して用いるているため、出力層だけの簡単なモデルです。
function training() {
const buttonField = document.getElementById("images-for-facepass")
buttonField.value = "training images now..."
model = tf.sequential({
layers: [
tf.layers.flatten({inputShape: [7, 7, 256]}),
tf.layers.dense({
units: 100,
activation: 'relu',
kernelInitializer: 'varianceScaling',
useBias: true
}),
tf.layers.dense({
units: 10,
kernelInitializer: 'varianceScaling',
useBias: false,
activation: 'softmax'
})
]
})
const optimizer = tf.train.adam(0.0001);
model.compile({optimizer: optimizer, loss: 'categoricalCrossentropy'});
const batchSize = Math.floor(controllerDataset.xs.shape[0] * 0.4);
if (!(batchSize > 0)) {
throw new Error(`Batch size is 0 or NaN. Please choose a non-zero fraction.`);
}
model.fit(controllerDataset.xs, controllerDataset.ys, {
batchSize,
epochs: 20,
callbacks: {
onBatchEnd: async () => await tf.nextFrame()
}
})
predicting()
}
学習が終了したら、WebCamを起動して画像を取り込む準備を行います。
##実際にWebCamから得られた画像データを用いて判定をする
実際にから得られるデータを変数を用いて取り込みこれを用いて判定を行います。1秒ごとにカメラから画像を読み込み作成されたモデルを通して結果をみています。
1以外の場合には認証失敗としています。判定の結果1が得られたなら認証成功、Succeededを表示し、1秒後に元のHTMLに戻しています。
function predicting() {
const buttonField = document.getElementById("images-for-facepass")
buttonField.value = "Ready!!!"
let videoElement = document.createElement("video");
videoElement.setAttribute("playsInline", true)
videoElement.setAttribute("muted", true)
videoElement.setAttribute("autoPlay", true)
videoElement.setAttribute("width", "224")
videoElement.setAttribute("height", "224")
const navigatorAny = navigator;
navigator.getUserMedia = navigator.getUserMedia || navigatorAny.webkitGetUserMedia || navigatorAny.mozGetUserMedia || navigatorAny.msGetUserMedia
if (navigator.getUserMedia) {
navigator.getUserMedia(
{video: true},
async stream => {
videoElement.srcObject = stream
let checking = true
while (checking) {
const classId = await predict(videoElement)
console.log(classId)
if (classId === 1) {
buttonField.value = "Succeeded"
checking = false
setTimeout(() => {
form_html.innerHTML = original_form_html
}, 1000)
}
}
},
error => console.log(error)
)
}
}
function predict (videoElement) {
return new Promise((solve, reject) => {
setTimeout(async () => {
const predictedClass = tf.tidy(() => {
const img = capture(videoElement);
const activation = mobilenet.predict(img);
const predictions = model.predict(activation);
return predictions.as1D().argMax();
});
const classId = (await predictedClass.data())[0];
solve(classId)
}, 1000);
})
}
やったぜ。
##唯一にして最大の問題点
0.5秒程度最初に元のGithubログインフォームのLoginボタンをクリックできる瞬間があるため、頑張れば画像認証しなくてもログインできる 笑笑
昨日としては面白いと思うし、拡張性もあるので、誰かがもっと面白いもの作ってくれるの期待してます 笑
もっと詳しく知りたいという方がいましたらもう少し、サーバーサイドも含めて詳しく書きます。