Webカメラで頭の位置を追い、窓を覗くようにカードの奥を見せる実装と検証

Webカメラで頭の位置を追い、窓を覗くようにカードの奥が見える表現を作りました。顔検出は押した時だけ読み込み、映像は保存も送信もしません。偽のカメラ映像を実 Chrome に流して検査した方法も記録します。

目次
  1. 何を作りたかったか
  2. カメラを使う処理
  3. 顔の位置を、奥行きの動きに変える
  4. カメラなしでも動かす、検査にも使う
  5. 本物のカメラが無くても検査する
  6. 「見られている」反応を足す
  7. 残る弱点
  8. まとめ

カードの前で頭を動かすと、窓の向こうを覗いたようにカードの奥と人物がずれて見える表現を作りました。Webカメラで頭の位置を取り、その位置から奥行きの動きを計算しています。顔検出(MediaPipe)は、ボタンを押した時にだけ読み込み、カメラの映像は端末の中だけで使い、保存も送信もしません。

  • カメラが使えない時やボタンを押さない時も、マウスや指で動く
  • 最初に見えた位置を「正面」にして、そこからの差だけを使う
  • 本物のカメラは試せないので、偽のカメラ映像を実 Chrome に流して検査した
  • 弱点は、人物がカードの大半を占めるので、奥の店内のずれが小さいこと

何を作りたかったか

この表現は、Webカメラで頭の位置を追い、箱の中を覗くように見せる Pokebox を参考にしました。カードの絵は、人物と奥の背景を別の層に分けてあります。頭の位置に合わせて層をずらせば、窓を覗くように奥がずれて見えます。

加えて、ひとつ仕掛けを入れました。人物の後ろに縦書きの言葉を隠しておき、頭を左へ動かすと、人物の左側から言葉が現れます。正面から見ると言葉は隠れていて、横から覗かないと読めない、という作りです。言葉を左からだけ出すのは、元の写真では人物の右側が腕で端まで埋まっていて、右には出す場所がないからです。これは、マスクを1行ずつ測って確認しました。

カメラを使う処理

顔検出には MediaPipe の Face Detector(blaze_face_short_range)を使いました。要点は、押した時にだけ読み込むことです。ページを開いただけでは、ライブラリもモデルも読み込まず、カメラも起動しません。

const start = async () => {
  stream = await navigator.mediaDevices.getUserMedia(
    { video: { facingMode: 'user', width: 640, height: 480 }, audio: false });
  video = document.createElement('video');
  video.playsInline = true; video.muted = true;
  video.srcObject = stream; await video.play();
  if (!det) {   // 初回だけ読み込む
    const m = await import(MP + '/vision_bundle.mjs');
    const fs = await m.FilesetResolver.forVisionTasks(MP + '/wasm');
    det = await m.FaceDetector.createFromOptions(fs, {
      baseOptions: { modelAssetPath: MODEL }, runningMode: 'VIDEO' });
  }
};

video 要素は画面に追加せず、検出のためだけに使います。映像を描画したり、保存したり、サーバーへ送ったりするコードはありません。止める時は、トラックを止めて参照を消します。

const stop = text => {
  if (stream) stream.getTracks().forEach(t => t.stop());
  stream = null; video = null; base = null;
  head.on = false; head.x = head.y = head.z = 0;
  // 画面の表示を元に戻す
};

カメラが許可されなかった時と、使えなかった時は、「マウスや指でも覗けます」と画面に出します。カメラが無い環境でも、体験は損なわれません。

顔の位置を、奥行きの動きに変える

毎コマ、検出した顔の位置と大きさを見て、カードの動きに変えます。

const d = det.detectForVideo(video, now).detections[0];
if (d) {
  const b = d.boundingBox;
  const cx = (b.originX + b.width / 2) / video.videoWidth;
  const cy = (b.originY + b.height / 2) / video.videoHeight;
  const w  = b.width / video.videoWidth;
  if (!base) base = { cx, cy, w };           // 最初に見えた位置が正面
  tgt = { x: clamp((cx - base.cx) * 5, -1, 1),
          y: clamp((cy - base.cy) * 5, -1, 1),
          z: clamp((w / base.w - 1) / .5, 0, 1) };
}
head.x += (tgt.x - head.x) * .2;             // なめらかに追う
head.y += (tgt.y - head.y) * .2;
head.z += (tgt.z - head.z) * .12;

考え方は3つです。

  1. 位置は、映像の幅・高さに対する割合にして、解像度によらず同じ値にします。
  2. 「正面」は固定の値ではなく、最初に顔が見えた位置にします。カメラの置き場所や座り方が違っても、そこが基準になります。基準からの差を5倍して、-1〜1に丸めます。
  3. 近づいたかどうかは、顔の幅が基準の何倍かで見ます。1.5倍で最大(1)になります。

そのまま使うと動きがぎくしゃくするので、目標の値へ少しずつ近づける処理(* .2、* .12)を挟みました。顔が900ミリ秒見つからなければ、目標を中央へ戻し、「顔が見つかりません」と表示します。

得られた x・y・z は、CSS の変数(--hx、--hy、--hz)としてカードに渡します。この変数に応じて、奥と人物がずれる仕組みです(ずれの量の細かい式は、この記事では省きます)。

カメラなしでも動かす、検査にも使う

マウスや指で動かせる経路は、利用者のためだけでなく、検査のためにも使いました。

window.setHead = (x, y, z) => {
  manual = true; head.on = true;
  tgt = { x, y, z }; head.x = x; head.y = y; head.z = z;
};

カメラなしで頭の位置を直接入れる関数です。これで、検出の部分とは切り離して、見た目の部分だけを確認できます。

本物のカメラが無くても検査する

困ったのは、「本物のカメラと本物の頭で試す」ことが私の環境では難しかったことです。そこで、偽のカメラ映像を使いました。写真を左右に動かした動画を、実 Chrome のカメラ入力として流します。写真の中には顔があるので、顔検出が反応します。

この方法で、次のことを確認しました。

  • 顔を約580回検出した
  • 左右の動きと、近づく動きに追従した
  • 映像を止めた時の動作も正常だった

あわせて、配色の自動検査(check-contrast)は合格しました。別のエージェントに目で見てもらった結果は、正面では言葉が隠れていること、横から覗くと2列とも読めることでした。

「見られている」反応を足す

実際に体験して、「このままだと体験が微妙」という指摘が出ました。そこで、カメラを使っている間は、人物が見ている人の方を向くようにしました。さらに、見つけた時、近い時、横から見た時、いなくなった時に、顔文字が吹き出しで出ます。コードの MOOD が、その対応表です。

目そのものを動かして視線を合わせる案は、顔の加工になるので、実施せず保留にしています。

残る弱点

  • 人物がカードの大半を占めるので、奥の店内のずれは小さい
  • 本物のカメラと本物の頭での体感は未確認(偽の映像だけ)
  • 検出モデルとライブラリは外部の配信元から読み込む。オフラインでは顔の追跡は使えず、マウスや指の操作だけになる

偽の映像での検査は、「検出して追従すること」の確認で、「気持ちよく動くか」の確認ではありません。この表現の良し悪しは、本物のカメラで見てからでないと判断できない、と考えています。

まとめ

Webカメラで頭の位置を追い、窓を覗くように奥を見せる表現を作りました。顔検出はボタンを押した時だけ読み込み、映像は保存も送信もしません。カメラがなくても指で動き、本物のカメラの代わりに偽の映像で検査しました。

他の表現の試作は 紙のカードにできない表現の記事 にあります。切り抜きの作り方は 切り抜きの記事 に書きました。