Webカメラで頭の位置を追い、窓を覗くようにカードの奥を見せる実装と検証
に公開
Webカメラで頭の位置を追い、窓を覗くようにカードの奥が見える表現を作りました。顔検出は押した時だけ読み込み、映像は保存も送信もしません。偽のカメラ映像を実 Chrome に流して検査した方法も記録します。
カードの前で頭を動かすと、窓の向こうを覗いたようにカードの奥と人物がずれて見える表現を作りました。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つです。
- 位置は、映像の幅・高さに対する割合にして、解像度によらず同じ値にします。
- 「正面」は固定の値ではなく、最初に顔が見えた位置にします。カメラの置き場所や座り方が違っても、そこが基準になります。基準からの差を5倍して、-1〜1に丸めます。
- 近づいたかどうかは、顔の幅が基準の何倍かで見ます。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カメラで頭の位置を追い、窓を覗くように奥を見せる表現を作りました。顔検出はボタンを押した時だけ読み込み、映像は保存も送信もしません。カメラがなくても指で動き、本物のカメラの代わりに偽の映像で検査しました。
他の表現の試作は 紙のカードにできない表現の記事 にあります。切り抜きの作り方は 切り抜きの記事 に書きました。