Gopherになるための技術

公開:2020/11/07
更新:2020/11/14
8 min読了の目安(約7900字TECH技術記事 1

成果物はここ

Go言語のマスコットGopherになれる!
カメラで人の姿勢を認識してそれに合わせてGopherを表示しようというものです。

技術の構成

  • PoseNet/TensorFlowJS
  • 代数幾何
  • ThreeJS
  • esm/esbuild

これらの技術を組み合わせて成果物を作ります。

PoseNetの基本

videoタグとユーザー操作による起点のためのボタンさえあれば良くて、
あとは後述のESモジュールスクリプトを読み込むだけ。

index.html

<video id="video" autoplay muted playsinline style="display: none"></video>
<button onclick="start">start</button>
<script type="module" src="script.js"></script>

ESモジュールはURLさえわかれば引き込めます。
本家のposenetはnode依存なのでそのままだと引き込めませんが、
すでにESモジュールに変換して公開済みであればそのURLを利用できます。
いろんな形式のJS資産をESモジュールに変換する話はあとで紹介します。

script.js

import * as posenet from "https://nobonobo.github.io/vgopher/posenet.js";

const resolution = 200;

async function loadVideo() {
  const video = document.getElementById("video");
  video.width = resolution;
  video.height = resolution;
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: false,
    video: {
      facingMode: "user",
      width: resolution,
      height: resolution,
    },
  });
  video.srcObject = stream;
  video.play();
  return new Promise((resolve) => {
    video.onloadeddata = () => {
      resolve(video);
    };
  });
}

globalThis.start = async () => {
  console.log("start");
  const video = await loadVideo();
  const net = await posenet.load({
    algorithm: "single-pose",
    architecture: "MobileNetV1",
    outputStride: 16,
    inputResolution: resolution,
    multiplier: 0.5,
    quantBytes: 2,
  });

  async function poseDetectionFrame() {
    const pose = await net.estimateSinglePose(video, {
      flipHorizontal: true,
      decodingMethod: "single-person",
    });
    console.log(pose);
    requestAnimationFrame(poseDetectionFrame);
  }
  poseDetectionFrame();
};

得られるposeの値は以下の通り。

{score: 0.40848546891528015, keypoints: Array}

keypoints: Array (17)
0 {score: 0.9991776347160339, part: "nose", position: {x: 129.42139620607998, y: 95.19844648133905}}
1 {score: 0.999355137348175, part: "leftEye", position: {x: 144.82679614131314, y: 72.28290775279307}}
2 {score: 0.99900883436203, part: "rightEye", position: {x: 109.41878402789021, y: 75.19295647971988}}
3 {score: 0.8983778953552246, part: "leftEar", position: {x: 170.3241536036674, y: 75.67895286441467}}
4 {score: 0.9788479804992676, part: "rightEar", position: {x: 88.43246222777687, y: 85.679681807602}}
5 {score: 0.7691003084182739, part: "leftShoulder", position: {x: 204.14748117713728, y: 164.55375394672927}}
6 {score: 0.9673877358436584, part: "rightShoulder", position: {x: 62.223072743786425, y: 159.76899300214538}}
7 {score: 0.03729431331157684, part: "leftElbow", position: {x: 114.44124211919122, y: 168.0716717181428}}
8 {score: 0.05844518169760704, part: "rightElbow", position: {x: 204.6458348091402, y: 181.5970307187095}}
9 {score: 0.07122055441141129, part: "leftWrist", position: {x: 147.2532756588002, y: 192.38907413779143}}
10 {score: 0.05842281132936478, part: "rightWrist", position: {x: 140.93272154812985, y: 190.72758966159324}}
11 {score: 0.039768464863300323, part: "leftHip", position: {x: 183.00264190515705, y: 181.97696666025743}}
12 {score: 0.024478904902935028, part: "rightHip", position: {x: 182.9715867116661, y: 180.7684547542908}}
13 {score: 0.014476188458502293, part: "leftKnee", position: {x: 131.47459672522666, y: 168.17489386840185}}
14 {score: 0.016538776457309723, part: "rightKnee", position: {x: 173.07119418920013, y: 192.5019101157707}}
15 {score: 0.016486596316099167, part: "leftAnkle", position: {x: 113.15661810840349, y: 192.66485500829825}}
16 {score: 0.014686128124594688, part: "rightAnkle", position: {x: 134.818595925761, y: 193.1584175386577}}

scoreは確かさで1.0に近ければ誤判定の可能性が低いということになります。上記の出力はバストアップな構図によるものなので肩より下の判定scoreが極端に悪くなっています。

得られる座標はビデオソースの横200x縦200の範囲の2D座標です。
flipHorizontalというパラメータは向かい合わせのカメラに合わせる場合にtrueにします。

なんの準備もなくさくっとPoseNetが利用できるのもESモジュールという仕組みのおかげです。

代数幾何について

PoseNetから得られた2D座標系から3D空間に投影します。

  • 鼻と肩のセンターを基準にします。
  • 両目の傾きから顔のロール(role)を求めます。
  • 両目と鼻の座標を顔の傾きに並行な座標系に投影します。
  • 目と鼻の間隔の左右比で顔のヨー(yaw)を求めます。
  • 目と鼻の高さの差から顔のピッチ(pitch)を求めます。
  • 顔は目と鼻と口と耳のグループでrole,yaw,pitchを適用します。
  • 鼻と肩センターの位置から体の傾きを求めます。

大まかな流れは以上です。

2Dの座標系を別の座標系に投影するには行列の積を使います。目と鼻が傾いて以下のように得られた状況で目鼻の位置を比較するのは面倒なので比較しやすい座標系に変換します。

このxの単位ベクトルとyの単位ベクトルを縦に並べた行列にもとの座標ベクトルをかけると

xの単位ベクトルとyの単位ベクトルを基とする座標系から見た座標を得られます。
これで目と鼻のそれぞれの座標を変換すれば、目鼻の横や縦の位置を比較しやすくなります。

xベクトルは両目の座標ベクトルの差を正規化すればもとまりますが、yはどうやって求めるか?
x成分とy成分を入れ替えて片方の符号をマイナスにすれば90度回転したベクトルが得られます。
つまり、y = {x: x.y, y: -x.x}これで求めることができます。

ThreeJSについて

最近のバージョンはESモジュール版も公開されています。

import * as THREE from "https://cdnjs.cloudflare.com/ajax/libs/three.js/r122/three.module.js";

これだけで依存解決は完了です。

ちなみにGopherのデザインはハードコードで書きました。

function makeGopher() {
  const baseMat = new THREE.MeshBasicMaterial({ color: 0x44ffff });
  const noseMat = new THREE.MeshBasicMaterial({ color: 0x222222 });
  const lipMat = new THREE.MeshBasicMaterial({ color: 0xffdddd });
  const whiteMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
  const blackMat = new THREE.MeshBasicMaterial({ color: 0x000000 });
  const eye1 = new THREE.Mesh(new THREE.SphereGeometry(25, 16, 16), whiteMat);
  const eye2 = eye1.clone();
  const pupil1 = new THREE.Mesh(new THREE.SphereGeometry(5, 8, 8), blackMat);
  const pupil2 = pupil1.clone();

  const earGeom = new THREE.Geometry();
  const ear1 = new THREE.Mesh(
    new THREE.SphereGeometry(20, 8, 8).scale(1, 1, 0.3)
  );
  ear1.position.set(75, 75, 0);
  ear1.updateMatrix();
  earGeom.merge(ear1.geometry, ear1.matrix);
  const ear2 = ear1.clone();
  ear2.position.set(-75, 75, 0);
  ear2.updateMatrix();
  earGeom.merge(ear2.geometry, ear2.matrix);
  const ears = new THREE.Mesh(earGeom, baseMat);
  const nose = new THREE.Mesh(new THREE.SphereGeometry(10, 8, 8), noseMat);
  const lip1 = new THREE.Mesh(new THREE.SphereGeometry(15, 8, 8), lipMat);
  const lip2 = lip1.clone();
  const tooth = new THREE.Mesh(new THREE.BoxGeometry(20, 20, 20), whiteMat);
  eye1.position.set(30, 30, 90);
  pupil1.position.set(35, 35, 110);
  eye2.position.set(-30, 30, 90);
  pupil2.position.set(-35, 35, 110);
  nose.position.set(0, 0, 110);
  lip1.position.set(10, -10, 105);
  lip2.position.set(-10, -10, 105);
  tooth.position.set(0, -20, 105);
  const face = new THREE.Group();
  face.add(eye1);
  face.add(pupil1);
  face.add(eye2);
  face.add(pupil2);
  face.add(ears);
  face.add(nose);
  face.add(lip1);
  face.add(lip2);
  face.add(tooth);

  const baseGeom = new THREE.Geometry();
  const body = new THREE.Mesh(new THREE.CylinderGeometry(100, 100, 150, 32));
  body.updateMatrix();
  baseGeom.merge(body.geometry, body.matrix);
  const head = new THREE.Mesh(
    new THREE.SphereGeometry(
      100,
      16,
      16,
      0,
      2 * Math.PI,
      1.5 * Math.PI,
      Math.PI
    )
  );
  head.position.set(0, 75, 0);
  head.updateMatrix();
  baseGeom.merge(head.geometry, head.matrix);
  const hip = new THREE.Mesh(
    new THREE.SphereGeometry(
      100,
      16,
      16,
      0,
      2 * Math.PI,
      0.5 * Math.PI,
      Math.PI
    )
  );
  hip.position.set(0, -75, 0);
  hip.updateMatrix();
  baseGeom.merge(hip.geometry, hip.matrix);

  const base = new THREE.Mesh(baseGeom, baseMat);
  face.position.set(0, 75, 0);
  const total = new THREE.Group();
  face.name = "face";
  base.name = "base";
  total.add(face);
  total.add(base);
  return total;
}

ESモジュールについて

  • 名前空間がモジュール単位で閉じています。
  • なので外部からアクセスしたいシンボルをexportしておくかglobalThisにぶら下げておきます。
  • ブラウザでモジュール読み込んだ場合「globalThis=window」です。
  • あらゆる形式のサードパーティライブラリをESモジュールに変換するのにesbuildというツール(Go言語製)がおすすめです。

まとめ

ソースコード: https://github.com/nobonobo/vgopher

ESMのおかげか結構さっくりつくれました。ESM+CDNがもっと定着すればバニラJSはもっと身近なモノになってくるのでしょう。