Webアプリを作るライブラリとしてp5.jsを勉強したので、これを使って面白いWebアプリを作ろうと思う。
特徴としては、グラフィックスやイベント処理が簡単にできたり、ml5.jsという機械学習ライブラリ組み合わせて、画像認識などが簡単に出来るということだったので、アイデアとして画像認識して何か音を出すというようなものを考えた。
夢を見たんだ、気持ちのいい夢なんだ
作ると決めた日は、色々とライブラリについて調べたり、機械学習を使って何をするかとか考えたり頭をフル回転させていたが、結局決めきれずに就寝。そして翌日起きる直前に、ゴムチューブをハサミでブチブチと切り続けるという夢をみた。

それが気持ちよかったので、そんな感触が感じられるアプリを作ってみようと考えた。
完成形
実際のアプリはここで実行できる。
Webブラウザにしっかりフォーカスが来ていないとキー入力などが効かないので、上記リンクをクリックして画像が出てから、その画像にマウスポインタを動かして、クリックして欲しい。
次に、Enterキーを押す。
すると、人差し指と親指の認識が始まって、指先を認識した位置に赤丸が付く。
赤いバー(チューブをイメージ)の右端をその指で摘むと、音が出て動かすことが出来るようになる。
指で摘んでいる最中にスペースキーを押すと、プチッという音とともにバーが切れて2つになり、徐々に垂れていく。指を離すと掴んでいるチューブが下に落ちていく。
注意すべきは、画像は正立像で作っているので、カメラに自分の顔や身体を入れてしまうと、脳が鏡像と勘違いしてしまって上手く動かせない。カメラには自分が映らないようにして操作して欲しい。
制作過程
まずは、p5.jsは何ができるのかを調べてみた。
-
グラフィックス処理
Webブラウザに描画エリアを設定して、そこに線や矩形、円といった幾何学図形を描画することができる。
また、カメラ入力を得て、同じエリアにカメラからの画像を出力することができる。 -
キーボード・マウス入力の取得、
キーボード入力やマウス入力は、関数一発で取得できる。例えば、keyPressedという関数を定義すると、勝手にキーボードのイベントハンドラになって、どのキーが押されたかもkeyという変数に入るという簡単さ。これだけでいいの?って思ってしまう。 -
音声入出力
マイクからの音声を入力して、その周波数特性も得られる。それを使えば音声認識なんかにも応用できる。また、オーディオファイルの音源をロードして再生することもできる。
そして、ml5.jsは、p5.jsと組み合わせて使える、機械学習ライブラリで、どうもTensorFlowのラッパーのようですが、これは単なる憶測です。
サンプルでは、handposeというものを使って指先を認識させていたが、ChatGPTによるとこれ以外にも、PoseNet,Image Classifire, YOLO, Face API といったものがあるようだ。
いや、他にもないか聞いたらいくらでも出てきそう。
作ってみた
最初、ChatGPTに出したプロンプトはこんな感じ。最初は鏡像として作ったので、左右が反対になっている。
p5.jsを使ってアプリケーションを作成する。
カメラの映像の中に、チューブを書く。映像の中に人間の左手を入れて、
人差し指、中指、親指でチューブをつまむ。つまんだ手を動かすと、
チューブの左先端が動いて、長さや方向が変わる。
チューブの右端は固定されたまま。
これで出来上がってきたコードを実行しながら、徐々にイメージに近づけていく。
ここから進まなくなった
ChatGPTに頼りきりで作っていたが、残念ながら思った通りのものは出来なかった。
- スペースを切って中央で切る
- 掴んでいるパーツが手を離すと落ちる
- 切れたパーツが垂れていく
といった機能を上手く実装できない。
このあたりは、幾何学的な扱いが必要になるので、非言語が苦手なChatGPTには出来ないのだろうか。
コードを見てみるが、図形の示すデータ構造も、意味もなく複雑になっているように見える。
仕方がないので、一旦ここで打ち切って、翌日ソースコードを直接改修することにした。
すでに150行を超えているので、日を改めてちょっと気合を入れたい。
コード修正
図形オブジェクトとしては、単なる線分が2つなのだが、ChatGPTのコードは線分を2本以上に拡張可能にしている。拡張性はあるが、複雑になってデバッグが大変なので、オブジェクトは2つに限定してみる。
あと、現在指で掴んでいるものが更新されていなかったので、それも修正した。
オブジェクトの代入が、リファレンスなのかコピーなのか、調べるのが面倒だったので、動かしてみて結果オーライで良しとした。リファレンスなんだろう。
切れたパーツは、長さを変えないまま円弧を描いて垂れていくようにしたかったが、この辺もChatGPTは正確なコードを出してくれなかった。やむなく全て自分で作ったが、物理法則とはちょっと程遠いものになったかも。
つかみ判定については、画像認識の精度を見ながら、ちょっと甘い設定にしたが、掴んでいないのに掴んでいるとか、離したのに掴んだままとかになる場合もあるので、ここもバランスを見て調整。
気になったこと
描画エリアを大きくしたかったので、createCanvas(640, 480)を、800,600に変えてみたのだが、認識した位置と実際の指先の位置がずれるという問題が発生した。
サンプルプログラムでは640,480で指定しているので、この値は変更できないような気がする。何がこの値を決めているのか。カメラの解像度の設定なのかな。
セキュリティの問題
音を出すために、音声出力デバイスを初期化している。p5.jsの開発環境では特に問題は出ないのだが、実際にWebサーバーに置いてブラウザ(Google Chrome)からアクセスすると、セキュリティのエラーが発生する。オーディオデバイスに出力するためには、キー入力とかマウスクリックなど、ユーザーの何らかのアクションが必要になるのだ。
なので、オーディオを初期化する部分は、Enterキーが入力されないと初期化されないようにしてある。オーディオが初期化されないと、処理がスタートしないので、Enterキーがプログラムの開始を意味することになった。
感想
このプログラムには、キー入力、音声出力、グラフィックス、動画再生、動画から物体の認識、といった様々な機能がぶち込まれており、普通に考えたら数日で出来るようなものではない。しかも、このライブラリの知識は殆どない状態から始めている。
それが、確かに思ったとおりではないにしても、そこそこ動くのだから、LLMの威力の凄まじさを感じた。
LLMへのプロンプトが同じでも、微妙に異なるコードが出る。ライブラリの使い方が微妙に違ったりする。このあたりは、どんなライブラリをどのように使うのかといった指示を入れておいた方がいいかも知れない。
まだまだ、こういったアプリ開発には慣れていないので、もっと場数を踏んでいきたいと思う。
LLMは非言語が苦手というのは以前から分かっていて、特に幾何学は不得手。使用したGPT-4oは多少改善されているかと期待していたが、やはり難しいみたいだ。
本当は物理法則をシミュレートしたかったが、そういうものは人間が設計して、LLMには部分的に頼むといった使い方になりそう。
仕様としてまとめたプロンプト
p5.js/ml5.jsを使ってアプリケーションを作成する。
カメラの映像の中に、チューブを書く。チューブの長さと太さは、入力フィールドでpixelで指定する。
映像の中に人間の左手を入れて、人差し指と親指でチューブをつまむ。
つまむんだ手を動かすと、チューブの左先端が動いて、長さや方向が変わる。チューブの右端は固定されたまま。
指で摘まんだ位置に、チューブの左先端があった時にだけ、そこから動かすとチューブが動くようにする。
チューブを動かしたときにだけ、その長さに応じて音を出す。長いほど周波数の高い音を出す。最初の音はA
チューブを掴んでいるときに、スペースキーを押すと、チューブが真ん中から切れる。(ここは本当はキーボードではなく、音声で操作したい)
切れた後は、指で摘まんでいる方は、切れた左先端が徐々に下に下がっていく。離すと下に落ちていく。
左端が固定されているチューブは、切った瞬間元の長さに戻る。そして、最初からやり直すことができる。
チューブの長さと太さをテキストで指定するところは、バランス考えると変更するところではないと思って、最終的に削除した。
このプロンプトでは、最初の切断できないものが生成される。
ソースコード
自分で手を加えて、完成させたものが以下のコード
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Handpose Tube Manipulation</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.4/p5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.4/addons/p5.dom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.4/addons/p5.sound.min.js"></script>
<script src="https://unpkg.com/ml5@latest/dist/ml5.min.js"></script>
<script src="sketch.js"></script>
</head>
<body>
<h3>注意!! カメラに顔や体を移さないように。上手く操作できなくなります</h3>
①Enterキーを押す<br>
②人差し指と親指の先に、赤丸が表示されるのを待つ<br>
③赤いバーの右端を、人差し指と親指でつかむようにする<br>
④つかんだまま、動かすと、それに応じて伸び縮みする<br>
⑤伸び縮みすると音が変化するのを楽しむ<br>
⑥スペースキーを押すと、赤いバーが切れる<br>
⑦つかんでいる指を広げると、落ちる<br>
⑧③に戻る
</body>
</html>
sketch.js
let video;
let handpose;
let predictions = [];
let tubeStart;
let tubeEnd;
let tubeParts = [];
let isPinching = false;
let initialLength;
let initialTubeThickness = 10; // 初期のチューブの太さ
let tubeThickness;
let osc;
let freqA = 440; // A4の周波数
let currentPart = null;
let tubeCut = false; // チューブが切られたかどうかを示すフラグ
let audioContextStarted = false;
let splitSound;
function setup() {
createCanvas(640, 480);
video = createCapture(VIDEO);
video.size(width, height);
video.hide();
handpose = ml5.handpose(video, modelReady);
handpose.on('predict', results => {
predictions = results;
});
tubeStart = createVector(50, height / 2); // 左端が固定される
tubeEnd = createVector(50 + (width - 50) / 4, height / 2); // 右端が初期長さで設定される
initialLength = p5.Vector.dist(tubeStart, tubeEnd);
tubeThickness = initialTubeThickness;
// チューブを一つのセグメントとして初期化
tubeParts = [{
start: tubeStart,
end: tubeEnd,
isFalling: false
}];
// Enterキーイベントリスナーを追加
window.addEventListener('keydown', startAudioContextOnEnter);
}
function startAudioContextOnEnter(event) {
if (event.key === 'Enter' && !audioContextStarted) {
startAudioContext();
}
}
function startAudioContext() {
userStartAudio().then(() => {
splitSound = new p5.SoundFile('button-09a.mp3');
osc = new p5.Oscillator('sine');
osc.start();
osc.amp(0); // 初期状態で音を消しておく
audioContextStarted = true;
});
}
function modelReady() {
console.log("Model ready!");
}
function draw() {
background(220);
image(video, 0, 0, width, height);
textSize(32); // テキストサイズを設定
fill(0); // テキスト色を黒に設定
textAlign(LEFT, CENTER); // テキストの位置を中央に設定
text('プチッと気持ちいい!', 80, height / 2+150); // テキストを描画
if (predictions.length > 0 && audioContextStarted) {
// 指先の認識
let hand = predictions[0];
let thumbTip = hand.landmarks[4]; // 親指の先端
let indexTip = hand.landmarks[8]; // 人差し指の先端
let thumb = createVector(thumbTip[0], thumbTip[1]);
let index = createVector(indexTip[0], indexTip[1]);
// 指先の位置に赤いドットを描画
fill(255, 0, 0);
noStroke();
ellipse(thumb.x, thumb.y, 10, 10);
ellipse(index.x, index.y, 10, 10);
// つまみ判定
// 親指と人差し指の間の距離が50未満ならつまむと判定
let pinch = p5.Vector.sub(index, thumb).mag() < 80;
// つまんでいるときに、チューブの端と指の距離によって、どのチューブを掴んでいるかを判定する
if (pinch) {
if (!isPinching) {
for (let part of tubeParts) {
if (part && !part.isFalling) {
let d = p5.Vector.dist(index, part.end);
if (d < 30) { // チューブの端をつまむ動作の判定(端との距離)
isPinching = true;
currentPart = part;
break;
}
}
}
}
// currentPart: つままれているパーツ
if (isPinching && currentPart) {
// 新しいつまんでいるパーツの終端
let newTubeEnd = createVector((index.x+thumb.x)/2, (index.y+thumb.y)/2);
// currentPartが、切ったパーツの時、回転する
if (tubeParts[1] && tubeParts[1] === currentPart) { // 切ったパーツを持っているとき
let currentLength = p5.Vector.dist(currentPart.start, currentPart.end);
// currentLengthを保存する動き
// tubeParts[1]の左端をゆっくりと下に垂らす
const dy = 4;// 調整可能な速度
const dx = -(currentPart.start.y - currentPart.end.y)/(currentPart.start.x - currentPart.end.x)*dy;
tubeParts[1].start.x += dx;
tubeParts[1].start.y += dy;
const t = currentLength / sqrt( (tubeParts[1].start.x-newTubeEnd.x)**2+(tubeParts[1].start.y-newTubeEnd.y)**2 );
// console.log(t,currentLength,tubeParts[1].start.x,newTubeEnd.x,tubeParts[1].start.y,newTubeEnd.y);
tubeParts[1].start.x = newTubeEnd.x + t * (tubeParts[1].start.x - newTubeEnd.x);
tubeParts[1].start.y = newTubeEnd.y + t * (tubeParts[1].start.y - newTubeEnd.y);
currentPart.end = newTubeEnd;
} else { // チューブをのばしている
let currentLength = p5.Vector.dist(currentPart.start, newTubeEnd);
if (currentLength !== p5.Vector.dist(currentPart.start, currentPart.end)) {
currentPart.end = newTubeEnd;
let freq = map(currentLength, 0, width, freqA / 2, freqA * 2); // 長さに応じて周波数を調整
osc.freq(freq);
osc.amp(0.5, 0.1); // 音を出す
}
}
} else {
osc.amp(0, 0.1); // 指を離すと音を消す
}
} else { // つまんでいない
if (isPinching && currentPart && tubeCut) {
// チューブを放したときに落下開始
currentPart.isFalling = true; // currentPart === tubeParts[1];
}
isPinching = false;
currentPart = null;
osc.amp(0, 0.1); // 指を離すと音を消す
}
// スペースキーを押すとチューブの中央で分割
if (keyIsPressed && key === ' ' && isPinching) {
if (!tubeCut) {
splitSound.play();
let part = tubeParts[0];
let midPoint = createVector((part.start.x + part.end.x) / 2, (part.start.y + part.end.y) / 2);
let newPart = {
start: midPoint,
end: part.end,
isFalling: false
};
tubeParts = [{ start: part.start, end: tubeEnd, isFalling: false }, newPart];
tubeCut = true; // チューブが切られたことを示す
currentPart = tubeParts[1];
osc.amp(0, 0.1); // 切った瞬間、音を消す
}
}
}
// チューブの描画
drawTubes(tubeParts);
// 落下中のチューブの処理 落下するパーツは常に tubeParts[1]
if( tubeParts[1] && tubeParts[1].isFalling ) {
let part = tubeParts[1];
part.start.y += 5; // 落下速度を設定
part.end.y += 5;
if (part.start.y > height && part.end.y > height) {
// 画面外に出たら削除
tubeParts[1] = null;
tubeCut = false;
}
}
}
function drawTubes(parts) {
let part = parts[0];
let currentLength = p5.Vector.dist(part.start, part.end);
tubeThickness = initialTubeThickness * sqrt(initialLength / currentLength);
drawTube(part.start, part.end, tubeThickness);
if( parts[1] ) {
drawTube(parts[1].start, parts[1].end,tubeThickness);
}
}
function drawTube(start, end, thickness) {
stroke(255, 0, 0);
strokeWeight(thickness);
line(start.x, start.y, end.x, end.y);
}
style.css (いらないな)
body {
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
}
h1 {
margin-top: 20px;
}
div {
margin-bottom: 10px;
}
#canvas-container {
position: relative;
}