今回はVue.jsとFlaskで手書き数字の認識を行って、結果の出力に合わせてCubismWeb(Live2D Cubism3SDK for Web)のモデルを動かしてみます。
アプリケーションのおおまかな動作
1. Vueのキャンバスにマウスで好きな数字を書く
2. キャンバスの内容を8×8に縮小して識別器に渡す
3. 識別器の判定結果をVueに返す
4. 判定結果を表示してCubismWebのモデルを更新する
5. ランダムで選ばれた数字(0~9)をキャンバスに書くことを依頼する
6. 依頼した数字と識別器の判定結果に応じてCubismWebのモデルを更新する
使用するイラスト
CubismWebで表示するモデルです。

© Unity Technologies Japan/UCL
上記モデルには以前の記事で Live2D のテンプレート「FaceRig」を適用しています。
テンプレートを適用する手順についてはこちらで紹介しています。

動作確認サンプル
ChromeとEdgeで動作を確認しています。(下のサンプルはEdge)
Live2Dモデルで手書き数字の認識
Vue.jsとFlaskで認識した数字を、Live2Dモデル(CubismWeb)に渡すまでの過程を紹介します。
1. scikit-learnで手書き数字を学習させる
機械学習のライブラリ「scikit-learn」にはサンプルとしてデータセットが用意されているので、今回はその中から手書き数字の光学認識を学習させます。
前回記事「アヤメの分類」と同様にpickleファイルを識別器で使用します。
2. キャンバスを配置する
マウスで数字を書くためのキャンバスを配置します。
<div id="canvas_container"> <h2>Draw Canvas</h2> <canvas id="draw_canvas" width="280" height="280" @mousemove="drag_draw"></canvas> </div>

3. メッセージを表示するエリアとボタンの設置
判定結果などを表示するメッセージウィンドウを設置します。
<div id="message_window"> <p id="mes">[[ message ]]</p> </div>
キャンバスの値(0,255)を識別器に送るボタンと、キャンバスをリセットするボタンを設置します。
<div id="answer"> <button @click="getAnswer" id="btn">try</button> <button @click="clear">Clear</button> </div>

4. キャンバス関連の処理を記述する
scikit-learnのサンプル(手書き数字)が8×8で書かれているので、それに合わせてキャンバスの内容も8×8に縮小して識別器に渡します。
識別器の精度を上げたい場合はMNISTなど別のデータセットで学習させた方が良さそうです。
<script>
const ans = new Vue({
el: "#exa",
delimiters: ["[[", "]]"],
data: {
message: '// 0~9の手書き数字を識別します //',
},
methods: {
update_message: function(str) {
this.message = str;
},
clear: function() {
const canvas = document.getElementById('draw_canvas');
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
},
drag_draw: function(e) {
if(!e.buttons) return;
const rect = e.target.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
this.draw(x, y);
},
draw: function(mx, my) {
const canvas = document.getElementById('draw_canvas');
const x = mx / canvas.clientWidth * canvas.width;
const y = my / canvas.clientHeight * canvas.height;
if (x < 0 || y < 0 || canvas.width < x || canvas.height < y) return;
const ctx = canvas.getContext('2d');
const r = 40 / 100.0 * (canvas.width / 8); //線の太さ40
ctx.beginPath();
ctx.fillStyle = 'white';
ctx.arc(x, y, r, 0, Math.PI * 2, true);
ctx.fill();
},
getAnswer:function() {
const inputWidth = inputHeight = 8;
const canvas = document.getElementById('draw_canvas')
const ctx = canvas.getContext('2d');
ctx.drawImage(canvas,0,0,inputWidth,inputHeight); //8×8にリサイズ
const img = ctx.getImageData(0,0,inputWidth,inputHeight).data;
//ネガポジ変換
for(let i = 0 ; i < img.length ; i+=4) {
img[i] = 255 - img[i]; //R
img[i+1] = 255 - img[i+1]; //G
img[i+2] = 255 - img[i+2]; //B
img[i+3] = img[i+3]; //A
}
const src = [];
for(let i = 0 ; i < img.length ; i+=4) { //値を格納[255, 255, 0, 0, ...]
src.push(Math.floor((img[i] + img[i+1] + img[i+2]) / 3.0));
}
ctx.fillStyle = 'black';
ctx.fillRect(0,0,inputWidth,inputHeight);
callback = this.update_message;
fetch('http://localhost:5000/numPred', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(src), //キャンバスの値を識別器に送る
}).then(function(res){
return res.json();
}).then(function(src) {
callback('・・・');
const count = 0;
const countup1 = function() {
if (typeof rnm === 'undefined') { //初回はランダムな数字を生成していないため
callback('「識別器の判定は ' + src.pred + ' です」');
} else if (rnm == src.pred) {
callback('「一致しました! 識別器の判定は ' + src.pred + ' です」');
} else {
callback('「識別器の判定は ' + src.pred + ' です・・・」');
}
}
setTimeout(countup1, 800);
const countup2 = function() {
const update_num = function(max) {
rnm = Math.floor(Math.random() * Math.floor(max));
return rnm;
}
rnm = update_num(10); //0~9でランダムな数字を生成
callback('// Canvasに数字の '+ rnm +' を書いてください //');
}
setTimeout(countup2, 7000);
}).catch(function(error) {
console.log(error)
})
}}
})
</script>

5. 識別器で数字を判定する
1.で作ったpickleファイルで数字の判定を行います。
def predictNum(params):
from sklearn.externals import joblib
forest = joblib.load('./trained-model/digit-clf.pkl')
pred = forest.predict([params])
return pred
Vueから送られてきたキャンバスの値を整えて判定を行います。
@app.route('/numPred', methods = ['GET', 'POST'])
def numPred():
src = request.json
params = np.asarray(src, dtype = float)
params = np.floor(16 -16 * (params / 255))
if request.method == 'POST':
# 上記の判定を行って、結果をVueに返す
pred = predictNum(params)
return make_response(jsonify({
'pred': pred.tolist()
}))
elif request.method == 'GET':
return render_template('numPred.html')

6. CubismWebの画面を表示する
前回記事(アヤメの分類)と同じ手順でCubismWebの画面を設置します。

7. 識別器の判定結果に合わせてCubismWebのモデルを更新する
メッセージウィンドウに出力されるテキストを利用して、条件を分岐させることにしました。
window.onload = () => {
let isCor = false;
let isInc = false;
let isAns = false;
const getbtn = document.getElementById("btn");
getbtn.onclick = () => {
const count = 0;
const countup = () => {
const getmes = document.getElementById("mes");
if (getmes.textContent.indexOf('一致') != -1) { //メッセージに「一致」というテキストがあったら
isCor = true;
} else if (getmes.textContent.indexOf('です・・・') != -1) {
isInc = true;
} else {
isAns = true;
}
this.onUpdate = () => {
//onUpdateの中身と同じ//
if (isCor === true) { //メッセージの内容に応じてモーションを更新する
for (let i = 0; i < this._models.getSize(); i++) {
this._models.at(i).startMotion(LAppDefine.MotionGroupAdd, 1, LAppDefine.PriorityNormal);
}} else if (isInc === true) {
for (let i = 0; i < this._models.getSize(); i++) {
this._models.at(i).startMotion(LAppDefine.MotionGroupAdd, 2, LAppDefine.PriorityNormal);
}} else if (isAns === true) {
for (let i = 0; i < this._models.getSize(); i++) {
this._models.at(i).startMotion(LAppDefine.MotionGroupAdd, 0, LAppDefine.PriorityNormal);
}} else {
return;
}
isCor = false;
isInc = false;
isAns = false;
}
}
setTimeout(countup, 1000); //メッセージが表示されるまで少し時間がかかるので、その分処理を遅らせる
}
}

